coco-framework 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.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- coco_openai/tools/custom_tool.py +111 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Helpers for OpenAI httpx client construction, transport tuning, and streaming.
|
|
2
|
+
|
|
3
|
+
Covers cached default client builders, proxy-aware variants for the
|
|
4
|
+
`openai_proxy` path, kernel-level TCP keepalive / `TCP_USER_TIMEOUT` socket
|
|
5
|
+
options, and the `_astream_with_chunk_timeout` wrapper that bounds per-chunk
|
|
6
|
+
wall-clock time on async SSE streams.
|
|
7
|
+
|
|
8
|
+
Client-builder boilerplate mirrors the patterns in `openai._base_client`;
|
|
9
|
+
socket-option tuning and the streaming timeout are original to this module.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import inspect
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import socket
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from typing import Any, TypeVar, cast
|
|
24
|
+
|
|
25
|
+
import openai
|
|
26
|
+
from pydantic import SecretStr
|
|
27
|
+
|
|
28
|
+
from coco_openai._compat import httpx
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
SocketOption = tuple[int, int, int]
|
|
33
|
+
|
|
34
|
+
# socket.TCP_KEEPIDLE etc. are absent on darwin/win32; use raw UAPI constants.
|
|
35
|
+
_LINUX_TCP_KEEPIDLE = 4
|
|
36
|
+
_LINUX_TCP_KEEPINTVL = 5
|
|
37
|
+
_LINUX_TCP_KEEPCNT = 6
|
|
38
|
+
_LINUX_TCP_USER_TIMEOUT = 18
|
|
39
|
+
|
|
40
|
+
# macOS: same semantics, different constants from <netinet/tcp.h>.
|
|
41
|
+
_DARWIN_TCP_KEEPALIVE = 0x10 # idle seconds before first probe
|
|
42
|
+
_DARWIN_TCP_KEEPINTVL = 0x101
|
|
43
|
+
_DARWIN_TCP_KEEPCNT = 0x102
|
|
44
|
+
|
|
45
|
+
# Mirrors the openai SDK's pool defaults. Hardcoded to avoid depending on
|
|
46
|
+
# an internal module path (openai._constants) that can move across SDK versions.
|
|
47
|
+
_DEFAULT_CONNECTION_LIMITS = httpx.Limits(
|
|
48
|
+
max_connections=1000,
|
|
49
|
+
max_keepalive_connections=100,
|
|
50
|
+
keepalive_expiry=5.0,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _int_env(name: str, default: int, *, allow_negative: bool = False) -> int:
|
|
55
|
+
"""Read an int env var with graceful fallback + discoverable warning.
|
|
56
|
+
|
|
57
|
+
Unparseable or (by default) negative values fall back to `default` and
|
|
58
|
+
emit a single `WARNING` naming the offending variable. A misconfigured
|
|
59
|
+
environment still loads, but operators see the fallback in their logs
|
|
60
|
+
rather than silently getting a surprising default.
|
|
61
|
+
"""
|
|
62
|
+
raw = os.environ.get(name)
|
|
63
|
+
if raw is None:
|
|
64
|
+
return default
|
|
65
|
+
try:
|
|
66
|
+
value = int(raw)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Invalid value for %s=%r (not an int); falling back to %d.",
|
|
70
|
+
name,
|
|
71
|
+
raw,
|
|
72
|
+
default,
|
|
73
|
+
)
|
|
74
|
+
return default
|
|
75
|
+
if not allow_negative and value < 0:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Invalid value for %s=%r (negative); falling back to %d.",
|
|
78
|
+
name,
|
|
79
|
+
raw,
|
|
80
|
+
default,
|
|
81
|
+
)
|
|
82
|
+
return default
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _float_env(name: str, default: float, *, allow_negative: bool = False) -> float:
|
|
87
|
+
"""Read a float env var with graceful fallback + discoverable warning.
|
|
88
|
+
|
|
89
|
+
See `_int_env`. Negative values are rejected by default so a typo in
|
|
90
|
+
`COCO_OPENAI_STREAM_CHUNK_TIMEOUT_S=-10` can't silently disable the
|
|
91
|
+
wrapper it was meant to configure.
|
|
92
|
+
"""
|
|
93
|
+
raw = os.environ.get(name)
|
|
94
|
+
if raw is None:
|
|
95
|
+
return default
|
|
96
|
+
try:
|
|
97
|
+
value = float(raw)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Invalid value for %s=%r (not a float); falling back to %s.",
|
|
101
|
+
name,
|
|
102
|
+
raw,
|
|
103
|
+
default,
|
|
104
|
+
)
|
|
105
|
+
return default
|
|
106
|
+
if not allow_negative and value < 0:
|
|
107
|
+
logger.warning(
|
|
108
|
+
"Invalid value for %s=%r (negative); falling back to %s.",
|
|
109
|
+
name,
|
|
110
|
+
raw,
|
|
111
|
+
default,
|
|
112
|
+
)
|
|
113
|
+
return default
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _filter_supported(opts: list[SocketOption]) -> list[SocketOption]:
|
|
118
|
+
"""Drop socket options the running platform rejects.
|
|
119
|
+
|
|
120
|
+
Probes each option against a throwaway socket via `setsockopt` and keeps
|
|
121
|
+
only those the kernel accepts. This keeps the library-computed defaults
|
|
122
|
+
non-fatal across platforms that don't implement every Linux option —
|
|
123
|
+
`TCP_USER_TIMEOUT` in particular is Linux-only and silently missing on
|
|
124
|
+
macOS, some minimal kernels, and older gVisor builds. Dropped options
|
|
125
|
+
are logged at `DEBUG` so an operator can confirm whether a kernel-level
|
|
126
|
+
knob took effect on their platform.
|
|
127
|
+
|
|
128
|
+
If the probe socket cannot be created (sandboxed runtimes, `pytest-socket`
|
|
129
|
+
under `--disable-socket`, tight seccomp policies), the input list is
|
|
130
|
+
returned unfiltered. This preserves the pass-through behavior used for
|
|
131
|
+
explicit user overrides: unsupported options will surface as a clear
|
|
132
|
+
`OSError` at the first real `connect()` rather than being silently
|
|
133
|
+
dropped during `ChatOpenAI` construction.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
137
|
+
except Exception:
|
|
138
|
+
# Broad catch is deliberate: `pytest_socket` under `--disable-socket`
|
|
139
|
+
# raises `SocketBlockedError` (a `RuntimeError`, not `OSError`), and
|
|
140
|
+
# seccomp/sandboxed runtimes have been observed to raise other
|
|
141
|
+
# `OSError` subclasses and `PermissionError`. The intent is "any
|
|
142
|
+
# inability to create a probe socket -> pass through unfiltered,"
|
|
143
|
+
# and narrowing the type would silently regress sandboxed CI.
|
|
144
|
+
return list(opts)
|
|
145
|
+
try:
|
|
146
|
+
supported: list[SocketOption] = []
|
|
147
|
+
dropped: list[SocketOption] = []
|
|
148
|
+
for level, optname, optval in opts:
|
|
149
|
+
try:
|
|
150
|
+
probe.setsockopt(level, optname, optval)
|
|
151
|
+
except OSError:
|
|
152
|
+
dropped.append((level, optname, optval))
|
|
153
|
+
continue
|
|
154
|
+
supported.append((level, optname, optval))
|
|
155
|
+
if dropped:
|
|
156
|
+
logger.debug(
|
|
157
|
+
"Dropped %d unsupported socket option(s) on %s: %s",
|
|
158
|
+
len(dropped),
|
|
159
|
+
sys.platform,
|
|
160
|
+
dropped,
|
|
161
|
+
)
|
|
162
|
+
return supported
|
|
163
|
+
finally:
|
|
164
|
+
probe.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _default_socket_options() -> tuple[SocketOption, ...]:
|
|
168
|
+
"""Return default TCP socket options, or `()` if disabled via env.
|
|
169
|
+
|
|
170
|
+
Always returns a tuple (never None) so callers and `@lru_cache` keys
|
|
171
|
+
remain uniform: `()` is the single shape for "no options".
|
|
172
|
+
|
|
173
|
+
Target behavior on Linux/gVisor with the full option set: silent peers
|
|
174
|
+
are surfaced within ~90-120s via `SO_KEEPALIVE` + `TCP_USER_TIMEOUT`
|
|
175
|
+
(keepalive path gives a ~90s floor at the defaults; `TCP_USER_TIMEOUT`
|
|
176
|
+
caps at 120s). On platforms that reject some options,
|
|
177
|
+
`_filter_supported` drops them and the bound degrades to whatever the
|
|
178
|
+
remaining options provide.
|
|
179
|
+
"""
|
|
180
|
+
if os.environ.get("COCO_OPENAI_TCP_KEEPALIVE", "1") == "0":
|
|
181
|
+
return ()
|
|
182
|
+
|
|
183
|
+
keepidle = _int_env("COCO_OPENAI_TCP_KEEPIDLE", 60)
|
|
184
|
+
keepintvl = _int_env("COCO_OPENAI_TCP_KEEPINTVL", 10)
|
|
185
|
+
keepcnt = _int_env("COCO_OPENAI_TCP_KEEPCNT", 3)
|
|
186
|
+
user_timeout_ms = _int_env("COCO_OPENAI_TCP_USER_TIMEOUT_MS", 120000)
|
|
187
|
+
|
|
188
|
+
opts: list[SocketOption] = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
|
|
189
|
+
if sys.platform == "linux":
|
|
190
|
+
opts += [
|
|
191
|
+
(socket.IPPROTO_TCP, _LINUX_TCP_KEEPIDLE, keepidle),
|
|
192
|
+
(socket.IPPROTO_TCP, _LINUX_TCP_KEEPINTVL, keepintvl),
|
|
193
|
+
(socket.IPPROTO_TCP, _LINUX_TCP_KEEPCNT, keepcnt),
|
|
194
|
+
(socket.IPPROTO_TCP, _LINUX_TCP_USER_TIMEOUT, user_timeout_ms),
|
|
195
|
+
]
|
|
196
|
+
elif sys.platform == "darwin":
|
|
197
|
+
opts += [
|
|
198
|
+
(socket.IPPROTO_TCP, _DARWIN_TCP_KEEPALIVE, keepidle),
|
|
199
|
+
(socket.IPPROTO_TCP, _DARWIN_TCP_KEEPINTVL, keepintvl),
|
|
200
|
+
(socket.IPPROTO_TCP, _DARWIN_TCP_KEEPCNT, keepcnt),
|
|
201
|
+
]
|
|
202
|
+
# Windows (win32): SO_KEEPALIVE only; per-option tuning requires WSAIoctl.
|
|
203
|
+
return tuple(_filter_supported(opts))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_PROXY_ENV_VARS = (
|
|
207
|
+
"HTTP_PROXY",
|
|
208
|
+
"HTTPS_PROXY",
|
|
209
|
+
"ALL_PROXY",
|
|
210
|
+
"http_proxy",
|
|
211
|
+
"https_proxy",
|
|
212
|
+
"all_proxy",
|
|
213
|
+
)
|
|
214
|
+
_proxy_env_warning_emitted = False
|
|
215
|
+
_proxy_env_bypass_info_emitted = False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _proxy_env_detected() -> bool:
|
|
219
|
+
"""True when httpx would pick up a proxy from env or system config.
|
|
220
|
+
|
|
221
|
+
Mirrors the surface httpx reads (`urllib.request.getproxies()` plus the
|
|
222
|
+
uppercase env var names) so a positive result means env-proxy
|
|
223
|
+
auto-detection is live on pre-PR code paths.
|
|
224
|
+
"""
|
|
225
|
+
if any(os.environ.get(name) for name in _PROXY_ENV_VARS):
|
|
226
|
+
return True
|
|
227
|
+
try:
|
|
228
|
+
return bool(urllib.request.getproxies())
|
|
229
|
+
except Exception:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _should_bypass_socket_options_for_proxy_env(
|
|
234
|
+
*,
|
|
235
|
+
http_socket_options: Sequence[SocketOption] | None,
|
|
236
|
+
http_client: Any,
|
|
237
|
+
http_async_client: Any,
|
|
238
|
+
openai_proxy: str | None,
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""True when default shape + env proxy detected → skip transport injection.
|
|
241
|
+
|
|
242
|
+
Preserves pre-PR behavior for apps relying on httpx's env-proxy
|
|
243
|
+
auto-detection. Only triggers when the user has made no explicit choice
|
|
244
|
+
that would signal they want the custom transport:
|
|
245
|
+
|
|
246
|
+
- `http_socket_options` left at `None` (default, not `()` or a sequence)
|
|
247
|
+
- `COCO_OPENAI_TCP_KEEPALIVE` is not `0` (kill-switch is its own path)
|
|
248
|
+
- No `http_client` or `http_async_client` supplied
|
|
249
|
+
- No `openai_proxy` supplied
|
|
250
|
+
- A proxy env var / system proxy is visible to httpx
|
|
251
|
+
|
|
252
|
+
If any of those are set, the user has opted in to the transport path
|
|
253
|
+
(directly or via `openai_proxy`) and normal behavior — including the
|
|
254
|
+
shadowed-proxy WARNING — applies. When the kill-switch is set,
|
|
255
|
+
`_default_socket_options` already returns `()`, so the bypass INFO
|
|
256
|
+
would be noise; route through the normal path instead.
|
|
257
|
+
"""
|
|
258
|
+
if http_socket_options is not None:
|
|
259
|
+
return False
|
|
260
|
+
if os.environ.get("COCO_OPENAI_TCP_KEEPALIVE", "1") == "0":
|
|
261
|
+
return False
|
|
262
|
+
if http_client is not None or http_async_client is not None:
|
|
263
|
+
return False
|
|
264
|
+
if openai_proxy:
|
|
265
|
+
return False
|
|
266
|
+
return _proxy_env_detected()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _log_proxy_env_bypass_once() -> None:
|
|
270
|
+
"""Emit a one-time INFO when the proxy-env bypass triggers.
|
|
271
|
+
|
|
272
|
+
Visibility for operators running with a custom log pipeline: the bypass
|
|
273
|
+
is the *safe* outcome (env-proxy auto-detection preserved), but it means
|
|
274
|
+
socket-level keepalive / `TCP_USER_TIMEOUT` aren't applied on this
|
|
275
|
+
instance. INFO-level, since it's not a problem — just a diagnostic.
|
|
276
|
+
"""
|
|
277
|
+
global _proxy_env_bypass_info_emitted
|
|
278
|
+
if _proxy_env_bypass_info_emitted:
|
|
279
|
+
return
|
|
280
|
+
_proxy_env_bypass_info_emitted = True
|
|
281
|
+
active = [name for name in _PROXY_ENV_VARS if os.environ.get(name)]
|
|
282
|
+
source = ", ".join(active) if active else "system proxy configuration"
|
|
283
|
+
logger.info(
|
|
284
|
+
"trustcoco-openai detected %s and no explicit `http_socket_options` / "
|
|
285
|
+
"`http_client` / `http_async_client` / `openai_proxy`; skipping the "
|
|
286
|
+
"custom `httpx` transport so httpx's env-proxy auto-detection applies. "
|
|
287
|
+
"Pass `http_socket_options=[...]` to opt back into kernel-level TCP "
|
|
288
|
+
"keepalive tuning on top of the env proxy.",
|
|
289
|
+
source,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _warn_if_proxy_env_shadowed(
|
|
294
|
+
socket_options: tuple[SocketOption, ...],
|
|
295
|
+
*,
|
|
296
|
+
openai_proxy: str | None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Warn once if a custom transport will shadow httpx's proxy auto-detection.
|
|
299
|
+
|
|
300
|
+
When `socket_options` is non-empty we pass a custom `httpx` transport,
|
|
301
|
+
which disables httpx's native proxy auto-detection — both the uppercase
|
|
302
|
+
`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` env vars and their lowercase
|
|
303
|
+
equivalents, plus macOS/Windows system proxy config. If the user
|
|
304
|
+
supplies `openai_proxy` explicitly we route through it and the env-var
|
|
305
|
+
handling is moot. Otherwise, a user whose app was transparently relying
|
|
306
|
+
on any of those sources will silently stop using them on upgrade —
|
|
307
|
+
emit a single WARNING so the behavior change is discoverable.
|
|
308
|
+
|
|
309
|
+
Detection uses `urllib.request.getproxies()` — the same surface httpx
|
|
310
|
+
reads — so lowercase env vars and macOS/Windows system proxy settings
|
|
311
|
+
are caught alongside the uppercase names.
|
|
312
|
+
"""
|
|
313
|
+
global _proxy_env_warning_emitted
|
|
314
|
+
if _proxy_env_warning_emitted or not socket_options or openai_proxy:
|
|
315
|
+
return
|
|
316
|
+
active = [name for name in _PROXY_ENV_VARS if os.environ.get(name)]
|
|
317
|
+
try:
|
|
318
|
+
detected = bool(urllib.request.getproxies())
|
|
319
|
+
except Exception:
|
|
320
|
+
detected = False
|
|
321
|
+
if not active and not detected:
|
|
322
|
+
return
|
|
323
|
+
_proxy_env_warning_emitted = True
|
|
324
|
+
if active:
|
|
325
|
+
source = ", ".join(active) + " set in environment"
|
|
326
|
+
else:
|
|
327
|
+
source = "system proxy configuration detected"
|
|
328
|
+
logger.warning(
|
|
329
|
+
"trustcoco-openai injected a custom httpx transport to apply "
|
|
330
|
+
"`http_socket_options`, which disables httpx's proxy "
|
|
331
|
+
"auto-detection (%s). Set "
|
|
332
|
+
"`COCO_OPENAI_TCP_KEEPALIVE=0` or pass `http_socket_options=()` "
|
|
333
|
+
"to restore default proxy behavior, or supply `openai_proxy` / your "
|
|
334
|
+
"own `http_client` / `http_async_client` to take full control.",
|
|
335
|
+
source,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _resolve_socket_options(
|
|
340
|
+
value: Sequence[SocketOption] | None,
|
|
341
|
+
) -> tuple[SocketOption, ...]:
|
|
342
|
+
"""Normalize the user-facing field to the tuple form builders expect.
|
|
343
|
+
|
|
344
|
+
- `None` => env-driven defaults (may itself be `()` if the user set
|
|
345
|
+
`COCO_OPENAI_TCP_KEEPALIVE=0`). This path runs through
|
|
346
|
+
`_filter_supported()` inside `_default_socket_options()` because
|
|
347
|
+
the library-computed option set is aspirational and silent degradation
|
|
348
|
+
is the right posture.
|
|
349
|
+
- Any other sequence (including empty) => retupled for cache hashability.
|
|
350
|
+
An empty tuple is the explicit "disabled" signal. A non-empty sequence
|
|
351
|
+
is passed verbatim — **not** filtered. The user chose these options
|
|
352
|
+
explicitly, so an unsupported constant should surface as a clear
|
|
353
|
+
`OSError` at connect time, not be silently dropped.
|
|
354
|
+
|
|
355
|
+
Always returns a tuple — never `None` — so downstream signatures take
|
|
356
|
+
`tuple[SocketOption, ...]` with `()` as the single "no options" shape.
|
|
357
|
+
"""
|
|
358
|
+
if value is None:
|
|
359
|
+
return _default_socket_options()
|
|
360
|
+
return tuple(value)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class _SyncHttpxClientWrapper(openai.DefaultHttpxClient):
|
|
364
|
+
"""Borrowed from openai._base_client."""
|
|
365
|
+
|
|
366
|
+
def __del__(self) -> None:
|
|
367
|
+
try:
|
|
368
|
+
if self.is_closed:
|
|
369
|
+
return
|
|
370
|
+
self.close()
|
|
371
|
+
except Exception: # noqa: S110
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _AsyncHttpxClientWrapper(openai.DefaultAsyncHttpxClient):
|
|
376
|
+
"""Borrowed from openai._base_client."""
|
|
377
|
+
|
|
378
|
+
def __del__(self) -> None:
|
|
379
|
+
try:
|
|
380
|
+
if self.is_closed:
|
|
381
|
+
return
|
|
382
|
+
# TODO(someday): support non asyncio runtimes here
|
|
383
|
+
asyncio.get_running_loop().create_task(self.aclose())
|
|
384
|
+
except Exception: # noqa: S110
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _build_sync_httpx_client(
|
|
389
|
+
base_url: str | None,
|
|
390
|
+
timeout: Any,
|
|
391
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
392
|
+
) -> _SyncHttpxClientWrapper:
|
|
393
|
+
kwargs: dict[str, Any] = {
|
|
394
|
+
"base_url": base_url
|
|
395
|
+
or os.environ.get("OPENAI_BASE_URL")
|
|
396
|
+
or "https://api.openai.com/v1",
|
|
397
|
+
"timeout": timeout,
|
|
398
|
+
}
|
|
399
|
+
if socket_options:
|
|
400
|
+
# httpx ignores limits= when transport= is provided; set it explicitly
|
|
401
|
+
# on the transport to avoid silently shrinking the connection pool.
|
|
402
|
+
kwargs["transport"] = httpx.HTTPTransport(
|
|
403
|
+
socket_options=list(socket_options),
|
|
404
|
+
limits=_DEFAULT_CONNECTION_LIMITS,
|
|
405
|
+
)
|
|
406
|
+
return _SyncHttpxClientWrapper(**kwargs)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _build_async_httpx_client(
|
|
410
|
+
base_url: str | None,
|
|
411
|
+
timeout: Any,
|
|
412
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
413
|
+
) -> _AsyncHttpxClientWrapper:
|
|
414
|
+
kwargs: dict[str, Any] = {
|
|
415
|
+
"base_url": base_url
|
|
416
|
+
or os.environ.get("OPENAI_BASE_URL")
|
|
417
|
+
or "https://api.openai.com/v1",
|
|
418
|
+
"timeout": timeout,
|
|
419
|
+
}
|
|
420
|
+
if socket_options:
|
|
421
|
+
# See _build_sync_httpx_client for the limits= rationale.
|
|
422
|
+
kwargs["transport"] = httpx.AsyncHTTPTransport(
|
|
423
|
+
socket_options=list(socket_options),
|
|
424
|
+
limits=_DEFAULT_CONNECTION_LIMITS,
|
|
425
|
+
)
|
|
426
|
+
return _AsyncHttpxClientWrapper(**kwargs)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_proxied_sync_httpx_client(
|
|
430
|
+
proxy: str,
|
|
431
|
+
verify: Any,
|
|
432
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
433
|
+
) -> httpx.Client:
|
|
434
|
+
"""httpx.Client for the openai_proxy code path.
|
|
435
|
+
|
|
436
|
+
When socket options are disabled (`()`), returns a plain
|
|
437
|
+
`httpx.Client(proxy=..., verify=...)` with no transport injected.
|
|
438
|
+
"""
|
|
439
|
+
if not socket_options:
|
|
440
|
+
return httpx.Client(proxy=proxy, verify=verify)
|
|
441
|
+
# Mount under `all://` (not `transport=`) so `Client._mounts` mirrors the
|
|
442
|
+
# shape produced by httpx's own `proxy=` path — a single-entry dict keyed
|
|
443
|
+
# by `URLPattern("all://")`. Callers (and the existing proxy integration
|
|
444
|
+
# test) reach into `_mounts` to introspect the proxy URL; a bare
|
|
445
|
+
# `transport=` leaves `_mounts` empty.
|
|
446
|
+
#
|
|
447
|
+
# `httpx.HTTPTransport(proxy=...)` is stricter about string coercion than
|
|
448
|
+
# `httpx.Client(proxy=...)`; wrap in the public `httpx.Proxy` type for
|
|
449
|
+
# version-stable behavior.
|
|
450
|
+
transport = httpx.HTTPTransport(
|
|
451
|
+
proxy=httpx.Proxy(proxy),
|
|
452
|
+
verify=verify,
|
|
453
|
+
socket_options=list(socket_options),
|
|
454
|
+
limits=_DEFAULT_CONNECTION_LIMITS,
|
|
455
|
+
)
|
|
456
|
+
return httpx.Client(mounts={"all://": transport})
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _build_proxied_async_httpx_client(
|
|
460
|
+
proxy: str,
|
|
461
|
+
verify: Any,
|
|
462
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
463
|
+
) -> httpx.AsyncClient:
|
|
464
|
+
"""httpx.AsyncClient for the openai_proxy code path.
|
|
465
|
+
|
|
466
|
+
See `_build_proxied_sync_httpx_client` for the opt-out fallback,
|
|
467
|
+
the `mounts={"all://": ...}` shape, and the `httpx.Proxy` wrapping
|
|
468
|
+
rationale.
|
|
469
|
+
"""
|
|
470
|
+
if not socket_options:
|
|
471
|
+
return httpx.AsyncClient(proxy=proxy, verify=verify)
|
|
472
|
+
transport = httpx.AsyncHTTPTransport(
|
|
473
|
+
proxy=httpx.Proxy(proxy),
|
|
474
|
+
verify=verify,
|
|
475
|
+
socket_options=list(socket_options),
|
|
476
|
+
limits=_DEFAULT_CONNECTION_LIMITS,
|
|
477
|
+
)
|
|
478
|
+
return httpx.AsyncClient(mounts={"all://": transport})
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@lru_cache
|
|
482
|
+
def _cached_sync_httpx_client(
|
|
483
|
+
base_url: str | None,
|
|
484
|
+
timeout: Any,
|
|
485
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
486
|
+
) -> _SyncHttpxClientWrapper:
|
|
487
|
+
return _build_sync_httpx_client(base_url, timeout, socket_options)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@lru_cache
|
|
491
|
+
def _cached_async_httpx_client(
|
|
492
|
+
base_url: str | None,
|
|
493
|
+
timeout: Any,
|
|
494
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
495
|
+
) -> _AsyncHttpxClientWrapper:
|
|
496
|
+
return _build_async_httpx_client(base_url, timeout, socket_options)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _get_default_httpx_client(
|
|
500
|
+
base_url: str | None,
|
|
501
|
+
timeout: Any,
|
|
502
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
503
|
+
) -> _SyncHttpxClientWrapper:
|
|
504
|
+
"""Get default httpx client.
|
|
505
|
+
|
|
506
|
+
Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
|
|
507
|
+
"""
|
|
508
|
+
try:
|
|
509
|
+
hash(timeout)
|
|
510
|
+
except TypeError:
|
|
511
|
+
return _build_sync_httpx_client(base_url, timeout, socket_options)
|
|
512
|
+
else:
|
|
513
|
+
return _cached_sync_httpx_client(base_url, timeout, socket_options)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _get_default_async_httpx_client(
|
|
517
|
+
base_url: str | None,
|
|
518
|
+
timeout: Any,
|
|
519
|
+
socket_options: tuple[SocketOption, ...] = (),
|
|
520
|
+
) -> _AsyncHttpxClientWrapper:
|
|
521
|
+
"""Get default httpx client.
|
|
522
|
+
|
|
523
|
+
Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
|
|
524
|
+
"""
|
|
525
|
+
try:
|
|
526
|
+
hash(timeout)
|
|
527
|
+
except TypeError:
|
|
528
|
+
return _build_async_httpx_client(base_url, timeout, socket_options)
|
|
529
|
+
else:
|
|
530
|
+
return _cached_async_httpx_client(base_url, timeout, socket_options)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _resolve_sync_and_async_api_keys(
|
|
534
|
+
api_key: SecretStr | Callable[[], str] | Callable[[], Awaitable[str]],
|
|
535
|
+
) -> tuple[str | None | Callable[[], str], str | Callable[[], Awaitable[str]]]:
|
|
536
|
+
"""Resolve sync and async API key values.
|
|
537
|
+
|
|
538
|
+
Because OpenAI and AsyncOpenAI clients support either sync or async callables for
|
|
539
|
+
the API key, we need to resolve separate values here.
|
|
540
|
+
"""
|
|
541
|
+
if isinstance(api_key, SecretStr):
|
|
542
|
+
sync_api_key_value: str | None | Callable[[], str] = api_key.get_secret_value()
|
|
543
|
+
async_api_key_value: str | Callable[[], Awaitable[str]] = (
|
|
544
|
+
api_key.get_secret_value()
|
|
545
|
+
)
|
|
546
|
+
elif callable(api_key):
|
|
547
|
+
if inspect.iscoroutinefunction(api_key):
|
|
548
|
+
async_api_key_value = api_key
|
|
549
|
+
sync_api_key_value = None
|
|
550
|
+
else:
|
|
551
|
+
sync_api_key_value = cast(Callable, api_key)
|
|
552
|
+
|
|
553
|
+
async def async_api_key_wrapper() -> str:
|
|
554
|
+
return await asyncio.get_running_loop().run_in_executor(
|
|
555
|
+
None, cast(Callable, api_key)
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
async_api_key_value = async_api_key_wrapper
|
|
559
|
+
|
|
560
|
+
return sync_api_key_value, async_api_key_value
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
T = TypeVar("T")
|
|
564
|
+
|
|
565
|
+
# On Python ≤3.10, asyncio.TimeoutError and builtins.TimeoutError are distinct
|
|
566
|
+
# hierarchies, so subclassing only asyncio.TimeoutError would not be caught by
|
|
567
|
+
# `except TimeoutError:`. On Python ≥3.11 they are the same object, so listing
|
|
568
|
+
# both bases would raise TypeError: duplicate base class. We resolve this at
|
|
569
|
+
# class-definition time.
|
|
570
|
+
_StreamChunkTimeoutBases: tuple[type, ...] = (
|
|
571
|
+
(asyncio.TimeoutError,)
|
|
572
|
+
if issubclass(asyncio.TimeoutError, TimeoutError)
|
|
573
|
+
else (asyncio.TimeoutError, TimeoutError)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class StreamChunkTimeoutError(*_StreamChunkTimeoutBases): # type: ignore[misc]
|
|
578
|
+
"""Raised when no streaming chunk arrives within `stream_chunk_timeout`.
|
|
579
|
+
|
|
580
|
+
`issubclass(StreamChunkTimeoutError, asyncio.TimeoutError)` and
|
|
581
|
+
`issubclass(StreamChunkTimeoutError, TimeoutError)` both hold on all
|
|
582
|
+
supported Python versions, so existing `except asyncio.TimeoutError:`
|
|
583
|
+
and `except TimeoutError:` handlers keep catching the exception. On
|
|
584
|
+
Python 3.11+ the two exceptions are the same object, so only
|
|
585
|
+
`asyncio.TimeoutError` appears in `__bases__`.
|
|
586
|
+
|
|
587
|
+
Structured attributes (`timeout_s`, `model_name`, `chunks_received`)
|
|
588
|
+
mirror the WARNING log's `extra=` payload so diagnostic code doesn't
|
|
589
|
+
need to regex the message.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
def __init__(
|
|
593
|
+
self,
|
|
594
|
+
timeout_s: float,
|
|
595
|
+
*,
|
|
596
|
+
model_name: str | None = None,
|
|
597
|
+
chunks_received: int = 0,
|
|
598
|
+
) -> None:
|
|
599
|
+
self.timeout_s = timeout_s
|
|
600
|
+
self.model_name = model_name
|
|
601
|
+
self.chunks_received = chunks_received
|
|
602
|
+
context = []
|
|
603
|
+
if model_name:
|
|
604
|
+
context.append(f"model={model_name}")
|
|
605
|
+
context.append(f"chunks_received={chunks_received}")
|
|
606
|
+
suffix = f" ({', '.join(context)})"
|
|
607
|
+
super().__init__(
|
|
608
|
+
f"No streaming chunk received for {timeout_s:.1f}s{suffix}. The "
|
|
609
|
+
f"connection may be alive at the TCP layer but is not producing "
|
|
610
|
+
f"content. Tune or disable via the `stream_chunk_timeout` "
|
|
611
|
+
f"constructor kwarg (set to None or 0 to disable) or the "
|
|
612
|
+
f"`COCO_OPENAI_STREAM_CHUNK_TIMEOUT_S` env var. See also "
|
|
613
|
+
f"`http_socket_options` for the kernel-level TCP timeout that "
|
|
614
|
+
f"catches dead TCP peers."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
async def _astream_with_chunk_timeout(
|
|
619
|
+
source: AsyncIterator[T],
|
|
620
|
+
timeout: float | None,
|
|
621
|
+
*,
|
|
622
|
+
model_name: str | None = None,
|
|
623
|
+
) -> AsyncIterator[T]:
|
|
624
|
+
"""Yield from `source` but bound the per-chunk wait time.
|
|
625
|
+
|
|
626
|
+
If `timeout` is None or <=0, yields directly with no wall-clock bound.
|
|
627
|
+
Otherwise, each `__anext__` is wrapped in
|
|
628
|
+
`asyncio.wait_for(..., timeout)`. A timeout raises
|
|
629
|
+
`StreamChunkTimeoutError` (a `TimeoutError` subclass) whose message
|
|
630
|
+
names the knob, the env-var override, the model, and how many chunks
|
|
631
|
+
were received before the stall. A single-line structured log also
|
|
632
|
+
fires at WARNING so the signal is visible in aggregate logging systems
|
|
633
|
+
even when the exception is caught upstream.
|
|
634
|
+
|
|
635
|
+
When the timeout is active, the source iterator is explicitly
|
|
636
|
+
`aclose()`-d on early exit (timeout, consumer break, any exception) so
|
|
637
|
+
the underlying httpx streaming connection is released promptly. The
|
|
638
|
+
pass-through branch (timeout disabled) relies on httpx's GC-driven
|
|
639
|
+
cleanup instead — matching the behavior of unwrapped streams.
|
|
640
|
+
"""
|
|
641
|
+
if not timeout or timeout <= 0:
|
|
642
|
+
async for item in source:
|
|
643
|
+
yield item
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
chunks_received = 0
|
|
647
|
+
it = source.__aiter__()
|
|
648
|
+
try:
|
|
649
|
+
while True:
|
|
650
|
+
try:
|
|
651
|
+
chunk = await asyncio.wait_for(it.__anext__(), timeout=timeout)
|
|
652
|
+
except StopAsyncIteration:
|
|
653
|
+
return
|
|
654
|
+
except asyncio.TimeoutError as e:
|
|
655
|
+
logger.warning(
|
|
656
|
+
"coco_openai.stream_chunk_timeout fired",
|
|
657
|
+
extra={
|
|
658
|
+
"source": "stream_chunk_timeout",
|
|
659
|
+
"timeout_s": timeout,
|
|
660
|
+
"model_name": model_name,
|
|
661
|
+
"chunks_received": chunks_received,
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
raise StreamChunkTimeoutError(
|
|
665
|
+
timeout,
|
|
666
|
+
model_name=model_name,
|
|
667
|
+
chunks_received=chunks_received,
|
|
668
|
+
) from e
|
|
669
|
+
chunks_received += 1
|
|
670
|
+
yield chunk
|
|
671
|
+
finally:
|
|
672
|
+
aclose = getattr(it, "aclose", None)
|
|
673
|
+
if aclose is not None:
|
|
674
|
+
try:
|
|
675
|
+
await aclose()
|
|
676
|
+
except Exception as cleanup_exc:
|
|
677
|
+
# Best-effort cleanup; don't mask the original exception,
|
|
678
|
+
# but leave a DEBUG trace so pool/transport bugs stay
|
|
679
|
+
# discoverable at the right log level.
|
|
680
|
+
logger.debug(
|
|
681
|
+
"aclose() during _astream_with_chunk_timeout cleanup "
|
|
682
|
+
"raised; ignoring",
|
|
683
|
+
exc_info=cleanup_exc,
|
|
684
|
+
)
|