Compare commits

..

7 Commits

Author SHA1 Message Date
Devin AI
ee8b3be8e5 fix(tracing): stop nagging users who declined tracing (#5665)
- When user explicitly declined tracing, skip the 'Tracing is disabled'
  message instead of showing it on every crew/flow execution
- Add CREWAI_SUPPRESS_TRACING_MESSAGES env var to let users fully
  suppress the message
- Remove duplicate identical if/else branches in all four
  _show_tracing_disabled_message implementations
- Add 24 tests covering suppression via env var, context var, and
  user-declined scenarios

Co-Authored-By: João <joao@crewai.com>
2026-04-30 04:52:51 +00:00
Matt Aitchison
c7f01048b7 feat(azure): forward credential_scopes to Azure AI Inference client (#5661)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* feat(azure): forward credential_scopes to Azure AI Inference client

Adds a credential_scopes field to the native Azure AI Inference
provider and a matching AZURE_CREDENTIAL_SCOPES env var
(comma-separated). The value is forwarded to ChatCompletionsClient /
AsyncChatCompletionsClient when set, letting keyless / Entra-based
callers target a specific Azure AD audience (e.g.
https://cognitiveservices.azure.com/.default) without subclassing the
provider. Matches the upstream azure.ai.inference SDK kwarg of the
same name.

Lazy build re-reads the env var so an LLM constructed at module
import (before deployment env vars are set) still picks up scopes —
same pattern as the existing AZURE_API_KEY / AZURE_ENDPOINT lazy
reads. to_config_dict round-trips the field.

* refactor(azure): tighten credential_scopes env handling

Address review feedback:
- Move os.getenv into the helper so AZURE_CREDENTIAL_SCOPES appears once
- Match the surrounding api_key/endpoint `or` style in the validator
- Drop the list() defensive copy in to_config_dict — every other field
  in that method (and the base class's `stop`) is assigned by reference
2026-04-29 16:52:29 -05:00
Greyson LaLonde
14c3963d2c fix(instructor): forward base_url and api_key to instructor.from_provider 2026-04-30 03:00:39 +08:00
Greyson LaLonde
feb2e715a3 fix(mcp): warn and return empty when native MCP server returns no tools 2026-04-30 02:41:01 +08:00
Kunal Karmakar
e0b86750c2 feat(azure): add Responses API support for Azure OpenAI provider (#5201)
* Support azure openai responses

* Revert function supported condition

* Revert comment deletion

* Update support stop words

* Add cassette based tests

* Fix linting
2026-04-29 11:12:11 -07:00
Greyson LaLonde
2a40316521 fix(llm): use validated messages variable in non-streaming handlers 2026-04-30 00:56:56 +08:00
Lucas Gomide
e2deac5575 feat(flow): support custom persistence key in @persist (#5649)
* feat(flow): add optional key param to @persist decorator

Allows users to specify which state attribute to use as the
persistence key instead of always defaulting to state.id.

Usage: @persist(key='conversation_id')

Falls back to state.id when key is not provided (no breaking change).
Raises ValueError if the specified key is missing or falsy on state.

* docs(flow): document @persist key parameter for custom persistence keys

* fix(flow): use explicit None check for persist key to avoid empty-string fallback

---------

Co-authored-by: iris-clawd <iris-clawd@anthropic.com>
Co-authored-by: iris-clawd <iris@crewai.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-04-29 12:41:20 -04:00
33 changed files with 1987 additions and 61 deletions

View File

@@ -380,6 +380,33 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### مفتاح استمرارية مخصص
افتراضيًا، يستخدم `@persist` الحقل `state.id` المُولّد تلقائيًا كمفتاح للاستمرارية. إذا كان لتدفقك معرّف خاص به — مثل `conversation_id` مشترك بين عدة جلسات — يمكنك تمرير الوسيط `key` ليستخدم `@persist` تلك السمة كـ UUID للتدفق:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
turn: int = 0
@persist(key="conversation_id") # استخدام حقل مخصص كمفتاح للاستمرارية
class ConversationFlow(Flow[ConversationState]):
@start()
def begin(self):
self.state.turn += 1
print(f"Conversation {self.state.conversation_id} turn {self.state.turn}")
# إعادة تشغيل المحادثة بنفس conversation_id يُعيد تحميل الحالة السابقة
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
يقرأ المزخرف القيمة من `state[key]` للحالات من نوع dict، ومن `getattr(state, key)` للحالات من نوع Pydantic / كائن. إذا كانت السمة المحددة غير موجودة أو قيمتها falsy عند الحفظ، يُطلق `@persist` خطأ `ValueError` مثل `Flow state is missing required persistence key 'conversation_id'`. عند حذف `key`، يظل السلوك الأصلي قائمًا ويُستخدم `state.id`.
### كيف تعمل
1. **تعريف الحالة الفريد**

View File

@@ -146,6 +146,15 @@ class ProductionFlow(Flow[AppState]):
# ...
```
افتراضيًا، يستخدم `@persist` الحقل `state.id` المُولّد تلقائيًا كمفتاح للحالة المحفوظة. إذا كان تطبيقك يمتلك معرّفًا طبيعيًا بالفعل — مثل `conversation_id` يربط عدة تشغيلات بنفس جلسة المستخدم — مرّره كـ `key` ليستخدمه المزخرف كـ UUID للتدفق. يُطلق `ValueError` إذا كانت السمة المحددة غير موجودة أو قيمتها falsy عند الحفظ.
```python
@persist(key="conversation_id")
class ProductionFlow(Flow[AppState]):
# يجب أن يحتوي AppState على conversation_id؛ استئناف الجلسة يُعيد تحميل الحالة السابقة
...
```
## الخلاصة
- **ابدأ بتدفق.**

View File

@@ -116,6 +116,33 @@ class PersistentCounterFlow(Flow[CounterState]):
return self.state.value
```
### استخدام مفتاح استمرارية مخصص
افتراضيًا، يستخدم `@persist()` الحقل `state.id` المُولّد تلقائيًا كمفتاح للحالة المحفوظة. عندما يكون لمجالك معرّف طبيعي بالفعل — مثل `conversation_id` يربط عدة تشغيلات للتدفق بنفس جلسة المستخدم — مرّره كوسيط `key` ليستخدمه `@persist` كـ UUID للتدفق بدلًا من `id`:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
history: list[str] = []
@persist(key="conversation_id")
class ConversationFlow(Flow[ConversationState]):
@start()
def greet(self):
self.state.history.append("hello")
return self.state.history
# تشغيل ثانٍ بنفس conversation_id يُعيد تحميل الحالة السابقة
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
بالنسبة للحالات من نوع dict يقرأ `@persist` القيمة من `state[key]`، ولحالات Pydantic / الكائنات يقرأها من `getattr(state, key)`. إذا كانت السمة المحددة غير موجودة أو قيمتها falsy عند حفظ الحالة، يُطلق `@persist` خطأ `ValueError` مثل `Flow state is missing required persistence key 'conversation_id'`، فيظهر الفشل فورًا بدلًا من فقد بيانات الاستمرارية بصمت. استدعاء `@persist()` بدون `key` يحافظ على السلوك الأصلي ويستخدم `state.id`.
## أنماط حالة متقدمة
### المنطق الشرطي المبني على الحالة

View File

@@ -380,6 +380,33 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### Custom Persistence Key
By default, `@persist` uses the auto-generated `state.id` field as the persistence key. If your flow models its own identifier — for example a `conversation_id` shared across sessions — you can pass a `key` argument and `@persist` will use that attribute as the flow UUID instead:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
turn: int = 0
@persist(key="conversation_id") # Use a custom field as the persistence key
class ConversationFlow(Flow[ConversationState]):
@start()
def begin(self):
self.state.turn += 1
print(f"Conversation {self.state.conversation_id} turn {self.state.turn}")
# Resuming the same conversation reloads its prior state by conversation_id
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
The decorator reads the value at `state[key]` for dict states, or `getattr(state, key)` for Pydantic / object states. If the named attribute is missing or falsy at save time, `@persist` raises a `ValueError` such as `Flow state is missing required persistence key 'conversation_id'`. When `key` is omitted, the existing behavior is preserved and `state.id` is used.
### How It Works
1. **Unique State Identification**

View File

@@ -146,6 +146,15 @@ class ProductionFlow(Flow[AppState]):
# ...
```
By default `@persist` keys saved state by the auto-generated `state.id`. If your application already has a natural identifier — for example a `conversation_id` that ties multiple runs to the same user session — pass it as `key` and the decorator will use that attribute as the flow UUID. A `ValueError` is raised if the named attribute is missing or falsy at save time.
```python
@persist(key="conversation_id")
class ProductionFlow(Flow[AppState]):
# AppState must expose conversation_id; resuming a session reloads its prior state
...
```
## Summary
- **Start with a Flow.**

View File

@@ -346,6 +346,33 @@ class SelectivePersistFlow(Flow):
return f"Complete with count {self.state['count']}"
```
#### Using a Custom Persistence Key
By default, `@persist()` keys persisted state by the flow's auto-generated `state.id`. When your domain already has a natural identifier — for example a `conversation_id` that ties multiple flow runs to the same user session — pass it as the `key` argument and `@persist` will use that attribute as the flow UUID instead of `id`:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
history: list[str] = []
@persist(key="conversation_id")
class ConversationFlow(Flow[ConversationState]):
@start()
def greet(self):
self.state.history.append("hello")
return self.state.history
# A second run with the same conversation_id reloads the prior state
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
For dict-based states `@persist` reads `state[key]`, and for Pydantic / object states it reads `getattr(state, key)`. If the named attribute is missing or falsy when state is being saved, `@persist` raises a `ValueError` like `Flow state is missing required persistence key 'conversation_id'`, so the failure surfaces immediately rather than silently dropping persisted data. Calling `@persist()` without `key` keeps the original behavior of using `state.id`.
## Advanced State Patterns

View File

@@ -373,6 +373,33 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### 사용자 지정 영속성 키
기본적으로 `@persist`는 자동 생성된 `state.id` 필드를 영속성 키로 사용합니다. 여러 세션에 걸쳐 공유되는 `conversation_id`처럼 플로우에 자체 식별자가 있는 경우, `key` 인자를 전달하면 `@persist`가 해당 속성을 플로우 UUID로 사용합니다:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
turn: int = 0
@persist(key="conversation_id") # 사용자 지정 필드를 영속성 키로 사용
class ConversationFlow(Flow[ConversationState]):
@start()
def begin(self):
self.state.turn += 1
print(f"Conversation {self.state.conversation_id} turn {self.state.turn}")
# 동일한 conversation_id로 다시 실행하면 이전 상태가 다시 로드됩니다
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
이 데코레이터는 dict 상태의 경우 `state[key]`에서, Pydantic / 객체 상태의 경우 `getattr(state, key)`에서 값을 읽습니다. 저장 시점에 지정된 속성이 없거나 falsy 값이면, `@persist`는 `Flow state is missing required persistence key 'conversation_id'`와 같은 `ValueError`를 발생시킵니다. `key`를 생략하면 기존 동작이 유지되어 `state.id`가 사용됩니다.
### 작동 방식
1. **고유 상태 식별**

View File

@@ -146,6 +146,15 @@ class ProductionFlow(Flow[AppState]):
# ...
```
기본적으로 `@persist`는 자동 생성된 `state.id`를 저장된 상태의 키로 사용합니다. 애플리케이션에 이미 자연스러운 식별자가 있는 경우 — 예를 들어 같은 사용자 세션에 속한 여러 실행을 묶는 `conversation_id` — `key`로 전달하면 데코레이터가 해당 속성을 플로우 UUID로 사용합니다. 저장 시점에 지정된 속성이 없거나 falsy 값이면 `ValueError`가 발생합니다.
```python
@persist(key="conversation_id")
class ProductionFlow(Flow[AppState]):
# AppState는 conversation_id를 노출해야 합니다; 세션을 재개하면 이전 상태가 다시 로드됩니다
...
```
## 요약
- **Flow로 시작하세요.**

View File

@@ -346,6 +346,33 @@ class SelectivePersistFlow(Flow):
return f"Complete with count {self.state['count']}"
```
#### 사용자 지정 영속성 키 사용하기
기본적으로 `@persist()`는 자동 생성된 `state.id`를 영속 상태의 키로 사용합니다. 도메인에 이미 자연스러운 식별자가 있는 경우 — 예를 들어 같은 사용자 세션에 속한 여러 플로우 실행을 묶는 `conversation_id` — `key` 인자로 전달하면 `@persist`는 `id` 대신 해당 속성을 플로우 UUID로 사용합니다:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
history: list[str] = []
@persist(key="conversation_id")
class ConversationFlow(Flow[ConversationState]):
@start()
def greet(self):
self.state.history.append("hello")
return self.state.history
# 동일한 conversation_id로 두 번째 실행 시 이전 상태가 다시 로드됩니다
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
dict 기반 상태의 경우 `@persist`는 `state[key]`를 읽고, Pydantic / 객체 상태의 경우 `getattr(state, key)`를 읽습니다. 상태가 저장될 때 지정된 속성이 없거나 falsy 값이면 `@persist`는 `Flow state is missing required persistence key 'conversation_id'`와 같은 `ValueError`를 발생시켜, 영속 데이터가 조용히 손실되는 대신 즉시 실패가 드러나도록 합니다. `key` 없이 `@persist()`를 호출하면 기존 동작대로 `state.id`가 사용됩니다.
## 고급 상태 패턴
### 상태 기반 조건부 로직

View File

@@ -193,6 +193,33 @@ Para um controle mais granular, você pode aplicar @persist em métodos específ
# (O código não é traduzido)
```
### Chave de Persistência Personalizada
Por padrão, `@persist` usa o campo `state.id` gerado automaticamente como chave de persistência. Se o seu flow já possui um identificador natural — por exemplo um `conversation_id` compartilhado entre sessões — você pode passar o argumento `key` e `@persist` usará esse atributo como UUID do flow:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
turn: int = 0
@persist(key="conversation_id") # Usa um campo personalizado como chave de persistência
class ConversationFlow(Flow[ConversationState]):
@start()
def begin(self):
self.state.turn += 1
print(f"Conversa {self.state.conversation_id} turno {self.state.turn}")
# Retomar a mesma conversa recarrega o estado anterior pelo conversation_id
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
O decorador lê o valor em `state[key]` para estados do tipo dicionário ou `getattr(state, key)` para estados Pydantic / objetos. Se o atributo informado estiver ausente ou for *falsy* no momento de salvar, `@persist` lança um `ValueError` como `Flow state is missing required persistence key 'conversation_id'`. Quando `key` é omitido, o comportamento original é preservado e `state.id` continua sendo usado.
### Como Funciona
1. **Identificação Única do Estado**

View File

@@ -146,6 +146,15 @@ class ProductionFlow(Flow[AppState]):
# ...
```
Por padrão, `@persist` usa o `state.id` gerado automaticamente como chave do estado salvo. Se a sua aplicação já tem um identificador natural — por exemplo um `conversation_id` que liga várias execuções à mesma sessão de usuário — passe-o como `key` e o decorador usará esse atributo como UUID do flow. Um `ValueError` é lançado se o atributo informado estiver ausente ou for *falsy* no momento de salvar.
```python
@persist(key="conversation_id")
class ProductionFlow(Flow[AppState]):
# AppState precisa expor conversation_id; retomar a sessão recarrega o estado anterior
...
```
## Resumo
- **Comece com um Flow.**

View File

@@ -167,6 +167,33 @@ Para mais controle, você pode aplicar `@persist()` em métodos específicos:
# código não traduzido
```
#### Usando uma Chave de Persistência Personalizada
Por padrão, `@persist()` usa o `state.id` gerado automaticamente como chave do estado persistido. Quando seu domínio já possui um identificador natural — por exemplo um `conversation_id` que liga várias execuções do flow à mesma sessão de usuário — passe-o como argumento `key` e `@persist` usará esse atributo como UUID do flow em vez de `id`:
```python
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class ConversationState(BaseModel):
conversation_id: str
history: list[str] = []
@persist(key="conversation_id")
class ConversationFlow(Flow[ConversationState]):
@start()
def greet(self):
self.state.history.append("hello")
return self.state.history
# Uma segunda execução com o mesmo conversation_id recarrega o estado anterior
flow = ConversationFlow(conversation_id="user-42")
flow.kickoff()
```
Para estados baseados em dicionário `@persist` lê `state[key]`, e para estados Pydantic / objetos lê `getattr(state, key)`. Se o atributo informado estiver ausente ou for *falsy* no momento em que o estado for salvo, `@persist` lança um `ValueError` como `Flow state is missing required persistence key 'conversation_id'`, fazendo com que a falha apareça imediatamente em vez de descartar silenciosamente os dados persistidos. Chamar `@persist()` sem `key` mantém o comportamento original de usar `state.id`.
## Padrões Avançados de Estado
### Lógica Condicional Baseada no Estado

View File

@@ -2272,17 +2272,13 @@ class Crew(FlowTrackable, BaseModel):
if should_suppress_tracing_messages():
return
# Don't nag users who have explicitly declined tracing
if has_user_declined_tracing():
return
console = Console()
if has_user_declined_tracing():
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Crew code
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
• Run: crewai traces enable"""
else:
message = """Info: Tracing is disabled.
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Crew code

View File

@@ -868,17 +868,13 @@ class TraceCollectionListener(BaseEventListener):
if should_suppress_tracing_messages():
return
# Don't nag users who have explicitly declined tracing
if has_user_declined_tracing():
return
console = Console()
if has_user_declined_tracing():
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Crew/Flow code
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
• Run: crewai traces enable"""
else:
message = """Info: Tracing is disabled.
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Crew/Flow code

View File

@@ -53,10 +53,19 @@ def set_suppress_tracing_messages(suppress: bool) -> object:
def should_suppress_tracing_messages() -> bool:
"""Check if tracing messages should be suppressed.
Checks the context variable first, then falls back to the
CREWAI_SUPPRESS_TRACING_MESSAGES environment variable.
Returns:
True if messages should be suppressed, False otherwise.
"""
return _suppress_tracing_messages.get()
if _suppress_tracing_messages.get():
return True
return os.getenv("CREWAI_SUPPRESS_TRACING_MESSAGES", "false").lower() in (
"true",
"1",
"yes",
)
def should_enable_tracing(*, override: bool | None = None) -> bool:

View File

@@ -145,16 +145,12 @@ To update, run: uv sync --upgrade-package crewai"""
if listener and listener.first_time_handler.is_first_time:
return
if not is_tracing_enabled_in_context():
if has_user_declined_tracing():
message = """Info: Tracing is disabled.
# Don't nag users who have explicitly declined tracing
if has_user_declined_tracing():
return
To enable tracing, do any one of these:
• Set tracing=True in your Crew/Flow code
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
• Run: crewai traces enable"""
else:
message = """Info: Tracing is disabled.
if not is_tracing_enabled_in_context():
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Crew/Flow code

View File

@@ -3546,17 +3546,13 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if should_suppress_tracing_messages():
return
# Don't nag users who have explicitly declined tracing
if has_user_declined_tracing():
return
console = Console()
if has_user_declined_tracing():
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Flow code
• Set CREWAI_TRACING_ENABLED=true in your project's .env file
• Run: crewai traces enable"""
else:
message = """Info: Tracing is disabled.
message = """Info: Tracing is disabled.
To enable tracing, do any one of these:
• Set tracing=True in your Flow code

View File

@@ -50,6 +50,7 @@ LOG_MESSAGES: Final[dict[str, str]] = {
"save_error": "Failed to persist state for method {}: {}",
"state_missing": "Flow instance has no state",
"id_missing": "Flow state must have an 'id' field for persistence",
"key_missing": "Flow state is missing required persistence key '{}'",
}
@@ -63,6 +64,7 @@ class PersistenceDecorator:
method_name: str,
persistence_instance: FlowPersistence,
verbose: bool = False,
key: str | None = None,
) -> None:
"""Persist flow state with proper error handling and logging.
@@ -74,9 +76,12 @@ class PersistenceDecorator:
method_name: Name of the method that triggered persistence
persistence_instance: The persistence backend to use
verbose: Whether to log persistence operations
key: Optional state attribute/key to use as the persistence key.
When None, falls back to ``state.id``.
Raises:
ValueError: If flow has no state or state lacks an ID
ValueError: If flow has no state, state lacks an ID, or the
requested ``key`` is missing or falsy on state.
RuntimeError: If state persistence fails
AttributeError: If flow instance lacks required state attributes
"""
@@ -85,19 +90,22 @@ class PersistenceDecorator:
if state is None:
raise ValueError("Flow instance has no state")
lookup_key = key if key is not None else "id"
flow_uuid: str | None = None
if isinstance(state, dict):
flow_uuid = state.get("id")
flow_uuid = state.get(lookup_key)
elif hasattr(state, "_unwrap"):
unwrapped = state._unwrap()
if isinstance(unwrapped, dict):
flow_uuid = unwrapped.get("id")
flow_uuid = unwrapped.get(lookup_key)
else:
flow_uuid = getattr(unwrapped, "id", None)
elif isinstance(state, BaseModel) or hasattr(state, "id"):
flow_uuid = getattr(state, "id", None)
flow_uuid = getattr(unwrapped, lookup_key, None)
elif isinstance(state, BaseModel) or hasattr(state, lookup_key):
flow_uuid = getattr(state, lookup_key, None)
if not flow_uuid:
if key is not None:
raise ValueError(LOG_MESSAGES["key_missing"].format(key))
raise ValueError("Flow state must have an 'id' field for persistence")
# Log state saving only if verbose is True
@@ -127,7 +135,7 @@ class PersistenceDecorator:
logger.error(error_msg)
raise ValueError(error_msg) from e
except (TypeError, ValueError) as e:
error_msg = LOG_MESSAGES["id_missing"]
error_msg = str(e) or LOG_MESSAGES["id_missing"]
if verbose:
PRINTER.print(error_msg, color="red")
logger.error(error_msg)
@@ -135,7 +143,9 @@ class PersistenceDecorator:
def persist(
persistence: FlowPersistence | None = None, verbose: bool = False
persistence: FlowPersistence | None = None,
verbose: bool = False,
key: str | None = None,
) -> Callable[[type | Callable[..., T]], type | Callable[..., T]]:
"""Decorator to persist flow state.
@@ -148,12 +158,16 @@ def persist(
persistence: Optional FlowPersistence implementation to use.
If not provided, uses SQLiteFlowPersistence.
verbose: Whether to log persistence operations. Defaults to False.
key: Optional name of the state attribute (for Pydantic/object states)
or dict key (for dict states) to use as the persistence key. When
``None`` (default) the decorator falls back to ``state.id``.
Returns:
A decorator that can be applied to either a class or method
Raises:
ValueError: If the flow state doesn't have an 'id' field
ValueError: If the flow state doesn't have an 'id' field, or the
specified ``key`` is missing or falsy on state.
RuntimeError: If state persistence fails
Example:
@@ -162,6 +176,10 @@ def persist(
@start()
def begin(self):
pass
@persist(key="conversation_id") # Custom persistence key
class MyFlow(Flow[MyState]):
...
"""
def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]:
@@ -207,7 +225,7 @@ def persist(
) -> Any:
result = await original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
self, method_name, actual_persistence, verbose, key
)
return result
@@ -237,7 +255,7 @@ def persist(
def method_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
result = original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
self, method_name, actual_persistence, verbose, key
)
return result
@@ -276,7 +294,7 @@ def persist(
else:
result = method_coro
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
flow_instance, method.__name__, actual_persistence, verbose, key
)
return cast(T, result)
@@ -295,7 +313,7 @@ def persist(
def method_sync_wrapper(flow_instance: Any, *args: Any, **kwargs: Any) -> T:
result = method(flow_instance, *args, **kwargs)
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
flow_instance, method.__name__, actual_persistence, verbose, key
)
return result

View File

@@ -1160,7 +1160,7 @@ class LLM(BaseLLM):
call_type=LLMCallType.LLM_CALL,
from_task=from_task,
from_agent=from_agent,
messages=params["messages"],
messages=messages,
usage=None,
)
return structured_response
@@ -1316,7 +1316,7 @@ class LLM(BaseLLM):
call_type=LLMCallType.LLM_CALL,
from_task=from_task,
from_agent=from_agent,
messages=params["messages"],
messages=messages,
usage=None,
)
return structured_response

View File

@@ -88,9 +88,24 @@ class AzureCompletion(BaseLLM):
response_format: type[BaseModel] | None = None
is_openai_model: bool = False
is_azure_openai_endpoint: bool = False
credential_scopes: list[str] | None = None
# Responses API settings
api: Literal["completions", "responses"] = "completions"
reasoning_effort: str | None = None
instructions: str | None = None
store: bool | None = None
previous_response_id: str | None = None
include: list[str] | None = None
builtin_tools: list[str] | None = None
parse_tool_outputs: bool = False
auto_chain: bool = False
auto_chain_reasoning: bool = False
max_completion_tokens: int | None = None
_client: Any = PrivateAttr(default=None)
_async_client: Any = PrivateAttr(default=None)
_responses_delegate: Any = PrivateAttr(default=None)
@model_validator(mode="before")
@classmethod
@@ -115,6 +130,10 @@ class AzureCompletion(BaseLLM):
data["api_version"] = (
data.get("api_version") or os.getenv("AZURE_API_VERSION") or "2024-06-01"
)
data["credential_scopes"] = (
data.get("credential_scopes")
or AzureCompletion._credential_scopes_from_env()
)
# Credentials and endpoint are validated lazily in `_init_clients`
# so the LLM can be constructed before deployment env vars are set.
@@ -140,6 +159,15 @@ class AzureCompletion(BaseLLM):
hostname == "openai.azure.com" or hostname.endswith(".openai.azure.com")
) and "/openai/deployments/" in endpoint
@staticmethod
def _credential_scopes_from_env() -> list[str] | None:
"""Read ``AZURE_CREDENTIAL_SCOPES`` (comma-separated) into a list."""
raw = os.getenv("AZURE_CREDENTIAL_SCOPES")
if not raw:
return None
scopes = [s.strip() for s in raw.split(",") if s.strip()]
return scopes or None
@model_validator(mode="after")
def _init_clients(self) -> AzureCompletion:
"""Eagerly build clients when credentials are available, otherwise
@@ -147,12 +175,89 @@ class AzureCompletion(BaseLLM):
import time even before deployment env vars are set.
"""
try:
self._client = self._build_sync_client()
self._async_client = self._build_async_client()
if self.api == "responses":
self._init_responses_delegate()
else:
self._client = self._build_sync_client()
self._async_client = self._build_async_client()
except ValueError:
pass
return self
def _init_responses_delegate(self) -> None:
"""Create an OpenAICompletion delegate for the Azure OpenAI Responses API.
The Azure OpenAI Responses API uses the standard OpenAI Python SDK
with a base_url pointing to the Azure resource's /openai/v1/ endpoint.
"""
from crewai.llms.providers.openai.completion import OpenAICompletion
base_url = self._get_responses_base_url()
delegate_kwargs: dict[str, Any] = {
"model": self.model,
"api_key": self.api_key,
"base_url": base_url,
"api": "responses",
"provider": "openai",
"stream": self.stream,
}
if self.temperature is not None:
delegate_kwargs["temperature"] = self.temperature
if self.top_p is not None:
delegate_kwargs["top_p"] = self.top_p
if self.max_tokens is not None:
delegate_kwargs["max_tokens"] = self.max_tokens
if self.max_completion_tokens is not None:
delegate_kwargs["max_completion_tokens"] = self.max_completion_tokens
if self.stop:
delegate_kwargs["stop"] = self.stop
if self.timeout is not None:
delegate_kwargs["timeout"] = self.timeout
if self.max_retries != 2:
delegate_kwargs["max_retries"] = self.max_retries
if self.reasoning_effort is not None:
delegate_kwargs["reasoning_effort"] = self.reasoning_effort
if self.instructions is not None:
delegate_kwargs["instructions"] = self.instructions
if self.store is not None:
delegate_kwargs["store"] = self.store
if self.previous_response_id is not None:
delegate_kwargs["previous_response_id"] = self.previous_response_id
if self.include is not None:
delegate_kwargs["include"] = self.include
if self.builtin_tools is not None:
delegate_kwargs["builtin_tools"] = self.builtin_tools
if self.parse_tool_outputs:
delegate_kwargs["parse_tool_outputs"] = self.parse_tool_outputs
if self.auto_chain:
delegate_kwargs["auto_chain"] = self.auto_chain
if self.auto_chain_reasoning:
delegate_kwargs["auto_chain_reasoning"] = self.auto_chain_reasoning
if self.response_format is not None:
delegate_kwargs["response_format"] = self.response_format
if self.additional_params:
delegate_kwargs["additional_params"] = self.additional_params
self._responses_delegate = OpenAICompletion(**delegate_kwargs)
def _get_responses_base_url(self) -> str:
"""Construct the base URL for the Azure OpenAI Responses API.
Extracts the scheme and host from the configured endpoint and appends
the ``/openai/v1/`` path required by the Azure OpenAI Responses API.
Returns:
The Responses API base URL, e.g.
``https://myresource.openai.azure.com/openai/v1/``
"""
if not self.endpoint:
raise ValueError("Azure endpoint is required for Responses API")
parsed = urlparse(self.endpoint)
base = f"{parsed.scheme}://{parsed.netloc}"
return f"{base}/openai/v1/"
def _build_sync_client(self) -> Any:
return ChatCompletionsClient(**self._make_client_kwargs())
@@ -188,12 +293,17 @@ class AzureCompletion(BaseLLM):
"Azure endpoint is required. Set AZURE_ENDPOINT environment "
"variable or pass endpoint parameter."
)
if self.credential_scopes is None:
self.credential_scopes = AzureCompletion._credential_scopes_from_env()
client_kwargs: dict[str, Any] = {
"endpoint": self.endpoint,
"credential": self._resolve_credential(),
}
if self.api_version:
client_kwargs["api_version"] = self.api_version
if self.credential_scopes:
client_kwargs["credential_scopes"] = self.credential_scopes
return client_kwargs
def _resolve_credential(self) -> Any:
@@ -252,6 +362,18 @@ class AzureCompletion(BaseLLM):
config["presence_penalty"] = self.presence_penalty
if self.max_tokens is not None:
config["max_tokens"] = self.max_tokens
if self.api != "completions":
config["api"] = self.api
if self.reasoning_effort is not None:
config["reasoning_effort"] = self.reasoning_effort
if self.instructions is not None:
config["instructions"] = self.instructions
if self.store is not None:
config["store"] = self.store
if self.max_completion_tokens is not None:
config["max_completion_tokens"] = self.max_completion_tokens
if self.credential_scopes:
config["credential_scopes"] = self.credential_scopes
return config
@staticmethod
@@ -357,10 +479,10 @@ class AzureCompletion(BaseLLM):
from_agent: Any | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call Azure AI Inference chat completions API.
"""Call Azure AI Inference API.
Args:
messages: Input messages for the chat completion
messages: Input messages
tools: List of tool/function definitions
callbacks: Callback functions (not used in native implementation)
available_functions: Available functions for tool calling
@@ -369,8 +491,19 @@ class AzureCompletion(BaseLLM):
response_model: Response model
Returns:
Chat completion response or tool call result
Completion response or tool call result
"""
if self.api == "responses":
return self._responses_delegate.call(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with llm_call_context():
try:
# Emit call started event
@@ -429,10 +562,10 @@ class AzureCompletion(BaseLLM):
from_agent: Any | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call Azure AI Inference chat completions API asynchronously.
"""Call Azure AI Inference API asynchronously.
Args:
messages: Input messages for the chat completion
messages: Input messages
tools: List of tool/function definitions
callbacks: Callback functions (not used in native implementation)
available_functions: Available functions for tool calling
@@ -441,8 +574,19 @@ class AzureCompletion(BaseLLM):
response_model: Pydantic model for structured output
Returns:
Chat completion response or tool call result
Completion response or tool call result
"""
if self.api == "responses":
return await self._responses_delegate.acall(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with llm_call_context():
try:
self._emit_call_started_event(
@@ -1178,6 +1322,32 @@ class AzureCompletion(BaseLLM):
return result
return {"total_tokens": 0}
@property
def last_response_id(self) -> str | None:
"""Get the last response ID from Responses API auto-chaining."""
if self._responses_delegate is not None:
result: str | None = self._responses_delegate.last_response_id
return result
return None
@property
def last_reasoning_items(self) -> list[Any] | None:
"""Get the last reasoning items from Responses API auto-chain reasoning."""
if self._responses_delegate is not None:
result: list[Any] | None = self._responses_delegate.last_reasoning_items
return result
return None
def reset_chain(self) -> None:
"""Reset the Responses API auto-chain state."""
if self._responses_delegate is not None:
self._responses_delegate.reset_chain()
def reset_reasoning_chain(self) -> None:
"""Reset the Responses API reasoning chain state."""
if self._responses_delegate is not None:
self._responses_delegate.reset_reasoning_chain()
async def aclose(self) -> None:
"""Close the async client and clean up resources.

View File

@@ -374,6 +374,7 @@ class MCPToolResolver:
"MCP connection failed due to event loop cleanup issues. "
"This may be due to authentication errors or server unavailability."
) from e
raise
except asyncio.CancelledError as e:
raise ConnectionError(
"MCP connection was cancelled. This may indicate an authentication "
@@ -401,6 +402,13 @@ class MCPToolResolver:
filtered_tools.append(tool)
tools_list = filtered_tools
if not tools_list:
self._logger.log(
"warning",
f"No tools discovered from MCP server: {server_name}",
)
return cast(list[BaseTool], []), []
def _client_factory() -> MCPClient:
transport, _ = self._create_transport(mcp_config)
return MCPClient(

View File

@@ -98,7 +98,14 @@ class InternalInstructor(Generic[T]):
else:
provider = "openai" # Default fallback
return instructor.from_provider(f"{provider}/{model_string}")
extra_kwargs: dict[str, Any] = {}
if self.llm is not None and not isinstance(self.llm, str):
for attr in ("base_url", "api_key"):
value = getattr(self.llm, attr, None)
if value is not None:
extra_kwargs[attr] = value
return instructor.from_provider(f"{provider}/{model_string}", **extra_kwargs)
def _extract_provider(self) -> str:
"""Extract provider from LLM model name.

View File

@@ -0,0 +1,133 @@
interactions:
- request:
body: '{"input":[{"role":"user","content":"Say hello in one sentence."}],"model":"gpt-5.2-chat"}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- application/json
accept-encoding:
- ACCEPT-ENCODING-XXX
authorization:
- AUTHORIZATION-XXX
connection:
- keep-alive
content-length:
- '89'
content-type:
- application/json
host:
- kkarmakar-ai-eus2.openai.azure.com
x-stainless-arch:
- X-STAINLESS-ARCH-XXX
x-stainless-async:
- 'false'
x-stainless-lang:
- python
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 2.32.0
x-stainless-read-timeout:
- X-STAINLESS-READ-TIMEOUT-XXX
x-stainless-retry-count:
- '0'
x-stainless-runtime:
- CPython
x-stainless-runtime-version:
- 3.13.12
method: POST
uri: https://fake-azure-endpoint.openai.azure.com/openai/v1/responses
response:
body:
string: "{\n \"id\": \"resp_0473c8c2b1c49f8c0069f23d0910e081958ebce72a734935c7\",\n
\ \"object\": \"response\",\n \"created_at\": 1777483017,\n \"status\":
\"completed\",\n \"background\": false,\n \"completed_at\": 1777483018,\n
\ \"content_filters\": [\n {\n \"blocked\": false,\n \"source_type\":
\"prompt\",\n \"content_filter_raw\": [],\n \"content_filter_results\":
{\n \"jailbreak\": {\n \"detected\": false,\n \"filtered\":
false\n },\n \"hate\": {\n \"filtered\": false,\n \"severity\":
\"safe\"\n },\n \"sexual\": {\n \"filtered\": false,\n
\ \"severity\": \"safe\"\n },\n \"violence\": {\n \"filtered\":
false,\n \"severity\": \"safe\"\n },\n \"self_harm\":
{\n \"filtered\": false,\n \"severity\": \"safe\"\n }\n
\ },\n \"content_filter_offsets\": {\n \"start_offset\": 0,\n
\ \"end_offset\": 368,\n \"check_offset\": 0\n }\n },\n
\ {\n \"blocked\": false,\n \"source_type\": \"completion\",\n
\ \"content_filter_raw\": [],\n \"content_filter_results\": {\n \"protected_material_code\":
{\n \"detected\": false,\n \"filtered\": false\n },\n
\ \"protected_material_text\": {\n \"detected\": false,\n \"filtered\":
false\n },\n \"hate\": {\n \"filtered\": false,\n \"severity\":
\"safe\"\n },\n \"sexual\": {\n \"filtered\": false,\n
\ \"severity\": \"safe\"\n },\n \"violence\": {\n \"filtered\":
false,\n \"severity\": \"safe\"\n },\n \"self_harm\":
{\n \"filtered\": false,\n \"severity\": \"safe\"\n }\n
\ },\n \"content_filter_offsets\": {\n \"start_offset\": 0,\n
\ \"end_offset\": 53,\n \"check_offset\": 0\n }\n }\n
\ ],\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\":
null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\":
null,\n \"model\": \"gpt-5.2-chat\",\n \"output\": [\n {\n \"id\":
\"rs_0473c8c2b1c49f8c0069f23d09f24481959bcf9fd847a9a475\",\n \"type\":
\"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_0473c8c2b1c49f8c0069f23d0a8ccc81958f776ad6016d7edd\",\n
\ \"type\": \"message\",\n \"status\": \"completed\",\n \"content\":
[\n {\n \"type\": \"output_text\",\n \"annotations\":
[],\n \"logprobs\": [],\n \"text\": \"Hello! \\ud83d\\ude0a\"\n
\ }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\":
true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\":
null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\":
\"medium\",\n \"summary\": null\n },\n \"safety_identifier\": null,\n
\ \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n
\ \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\":
\"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\":
0,\n \"top_p\": 0.85,\n \"truncation\": \"disabled\",\n \"usage\": {\n
\ \"input_tokens\": 12,\n \"input_tokens_details\": {\n \"cached_tokens\":
0\n },\n \"output_tokens\": 22,\n \"output_tokens_details\": {\n
\ \"reasoning_tokens\": 0\n },\n \"total_tokens\": 34\n },\n \"user\":
null,\n \"metadata\": {}\n}"
headers:
Content-Length:
- '3203'
Content-Type:
- application/json
Date:
- Wed, 29 Apr 2026 17:16:59 GMT
Strict-Transport-Security:
- STS-XXX
apim-request-id:
- APIM-REQUEST-ID-XXX
skip-error-remapping:
- 'true'
x-content-type-options:
- X-CONTENT-TYPE-XXX
x-ms-client-request-id:
- X-MS-CLIENT-REQUEST-ID-XXX
x-ms-is-spilled-over:
- 'false'
x-ms-region:
- X-MS-REGION-XXX
x-ratelimit-abusepenalty-active:
- 'False'
x-ratelimit-key:
- gpt-5.2-chat
x-ratelimit-limit-requests:
- X-RATELIMIT-LIMIT-REQUESTS-XXX
x-ratelimit-limit-tokens:
- X-RATELIMIT-LIMIT-TOKENS-XXX
x-ratelimit-remaining-requests:
- X-RATELIMIT-REMAINING-REQUESTS-XXX
x-ratelimit-remaining-tokens:
- X-RATELIMIT-REMAINING-TOKENS-XXX
x-ratelimit-renewalperiod-requests:
- '60'
x-ratelimit-renewalperiod-tokens:
- '60'
x-ratelimit-reset-requests:
- X-RATELIMIT-RESET-REQUESTS-XXX
x-ratelimit-reset-tokens:
- X-RATELIMIT-RESET-TOKENS-XXX
x-request-id:
- X-REQUEST-ID-XXX
status:
code: 200
message: OK
version: 1

View File

@@ -0,0 +1,137 @@
interactions:
- request:
body: '{"input":[{"role":"user","content":"What is 2 + 2? Be brief."}],"model":"gpt-5.2-chat","tools":[{"type":"web_search_preview"}]}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- application/json
accept-encoding:
- ACCEPT-ENCODING-XXX
authorization:
- AUTHORIZATION-XXX
connection:
- keep-alive
content-length:
- '127'
content-type:
- application/json
host:
- kkarmakar-ai-eus2.openai.azure.com
x-stainless-arch:
- X-STAINLESS-ARCH-XXX
x-stainless-async:
- 'false'
x-stainless-lang:
- python
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 2.32.0
x-stainless-read-timeout:
- X-STAINLESS-READ-TIMEOUT-XXX
x-stainless-retry-count:
- '0'
x-stainless-runtime:
- CPython
x-stainless-runtime-version:
- 3.13.12
method: POST
uri: https://fake-azure-endpoint.openai.azure.com/openai/v1/responses
response:
body:
string: "{\n \"id\": \"resp_0d80ad9adad65fca0069f23d0c904c8194862acae4bd866cf5\",\n
\ \"object\": \"response\",\n \"created_at\": 1777483020,\n \"status\":
\"completed\",\n \"background\": false,\n \"completed_at\": 1777483022,\n
\ \"content_filters\": [\n {\n \"blocked\": false,\n \"source_type\":
\"prompt\",\n \"content_filter_raw\": [],\n \"content_filter_results\":
{\n \"jailbreak\": {\n \"detected\": false,\n \"filtered\":
false\n },\n \"hate\": {\n \"filtered\": false,\n \"severity\":
\"safe\"\n },\n \"sexual\": {\n \"filtered\": false,\n
\ \"severity\": \"safe\"\n },\n \"violence\": {\n \"filtered\":
false,\n \"severity\": \"safe\"\n },\n \"self_harm\":
{\n \"filtered\": false,\n \"severity\": \"safe\"\n }\n
\ },\n \"content_filter_offsets\": {\n \"start_offset\": 0,\n
\ \"end_offset\": 19017,\n \"check_offset\": 0\n }\n },\n
\ {\n \"blocked\": false,\n \"source_type\": \"completion\",\n
\ \"content_filter_raw\": [],\n \"content_filter_results\": {\n \"hate\":
{\n \"filtered\": false,\n \"severity\": \"safe\"\n },\n
\ \"sexual\": {\n \"filtered\": false,\n \"severity\":
\"safe\"\n },\n \"violence\": {\n \"filtered\": false,\n
\ \"severity\": \"safe\"\n },\n \"self_harm\": {\n \"filtered\":
false,\n \"severity\": \"safe\"\n },\n \"protected_material_code\":
{\n \"detected\": false,\n \"filtered\": false\n },\n
\ \"protected_material_text\": {\n \"detected\": false,\n \"filtered\":
false\n }\n },\n \"content_filter_offsets\": {\n \"start_offset\":
0,\n \"end_offset\": 889,\n \"check_offset\": 0\n }\n }\n
\ ],\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\":
null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\":
null,\n \"model\": \"gpt-5.2-chat\",\n \"output\": [\n {\n \"id\":
\"rs_0d80ad9adad65fca0069f23d0d8b8c8194b1a9ab61ddc3420d\",\n \"type\":
\"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_0d80ad9adad65fca0069f23d0e262081949c36d6cc1958eeed\",\n
\ \"type\": \"message\",\n \"status\": \"completed\",\n \"content\":
[\n {\n \"type\": \"output_text\",\n \"annotations\":
[],\n \"logprobs\": [],\n \"text\": \"2 + 2 = 4.\"\n }\n
\ ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\":
true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\":
null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\":
\"medium\",\n \"summary\": null\n },\n \"safety_identifier\": null,\n
\ \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n
\ \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\":
\"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [\n {\n \"type\":
\"web_search_preview\",\n \"search_content_types\": [\n \"text\"\n
\ ],\n \"search_context_size\": \"medium\",\n \"user_location\":
{\n \"type\": \"approximate\",\n \"city\": null,\n \"country\":
\"US\",\n \"region\": null,\n \"timezone\": null\n }\n
\ }\n ],\n \"top_logprobs\": 0,\n \"top_p\": 0.85,\n \"truncation\":
\"disabled\",\n \"usage\": {\n \"input_tokens\": 4312,\n \"input_tokens_details\":
{\n \"cached_tokens\": 0\n },\n \"output_tokens\": 28,\n \"output_tokens_details\":
{\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 4340\n },\n
\ \"user\": null,\n \"metadata\": {}\n}"
headers:
Content-Length:
- '3507'
Content-Type:
- application/json
Date:
- Wed, 29 Apr 2026 17:17:03 GMT
Strict-Transport-Security:
- STS-XXX
apim-request-id:
- APIM-REQUEST-ID-XXX
skip-error-remapping:
- 'true'
x-content-type-options:
- X-CONTENT-TYPE-XXX
x-ms-client-request-id:
- X-MS-CLIENT-REQUEST-ID-XXX
x-ms-is-spilled-over:
- 'false'
x-ms-region:
- X-MS-REGION-XXX
x-ratelimit-abusepenalty-active:
- 'False'
x-ratelimit-key:
- gpt-5.2-chat
x-ratelimit-limit-requests:
- X-RATELIMIT-LIMIT-REQUESTS-XXX
x-ratelimit-limit-tokens:
- X-RATELIMIT-LIMIT-TOKENS-XXX
x-ratelimit-remaining-requests:
- X-RATELIMIT-REMAINING-REQUESTS-XXX
x-ratelimit-remaining-tokens:
- X-RATELIMIT-REMAINING-TOKENS-XXX
x-ratelimit-renewalperiod-requests:
- '60'
x-ratelimit-renewalperiod-tokens:
- '60'
x-ratelimit-reset-requests:
- X-RATELIMIT-RESET-REQUESTS-XXX
x-ratelimit-reset-tokens:
- X-RATELIMIT-RESET-TOKENS-XXX
x-request-id:
- X-REQUEST-ID-XXX
status:
code: 200
message: OK
version: 1

View File

@@ -0,0 +1,84 @@
interactions:
- request:
body: '{"messages": [{"role": "user", "content": "Say hello in one sentence."}],
"stream": false}'
headers:
Accept:
- application/json
Connection:
- keep-alive
Content-Length:
- '90'
Content-Type:
- application/json
User-Agent:
- X-USER-AGENT-XXX
accept-encoding:
- ACCEPT-ENCODING-XXX
api-key:
- X-API-KEY-XXX
authorization:
- AUTHORIZATION-XXX
x-ms-client-request-id:
- X-MS-CLIENT-REQUEST-ID-XXX
method: POST
uri: https://fake-azure-endpoint.openai.azure.com/openai/deployments/gpt-5.2-chat/chat/completions?api-version=2024-02-15-preview
response:
body:
string: "{\"choices\":[{\"content_filter_results\":{\"hate\":{\"filtered\":false,\"severity\":\"safe\"},\"protected_material_code\":{\"detected\":false,\"filtered\":false},\"protected_material_text\":{\"detected\":false,\"filtered\":false},\"self_harm\":{\"filtered\":false,\"severity\":\"safe\"},\"sexual\":{\"filtered\":false,\"severity\":\"safe\"},\"violence\":{\"filtered\":false,\"severity\":\"safe\"}},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null,\"message\":{\"annotations\":[],\"content\":\"Hello!
\U0001F60A\",\"refusal\":null,\"role\":\"assistant\"}}],\"created\":1777483024,\"id\":\"chatcmpl-Da2oyIDHFopG5fmCKbhDiEYG5ciBN\",\"model\":\"gpt-5.2-chat-latest\",\"object\":\"chat.completion\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{\"hate\":{\"filtered\":false,\"severity\":\"safe\"},\"jailbreak\":{\"detected\":false,\"filtered\":false},\"self_harm\":{\"filtered\":false,\"severity\":\"safe\"},\"sexual\":{\"filtered\":false,\"severity\":\"safe\"},\"violence\":{\"filtered\":false,\"severity\":\"safe\"}}}],\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":13,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":12,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cached_tokens\":0},\"total_tokens\":25}}\n"
headers:
Content-Length:
- '1233'
Content-Type:
- application/json
Date:
- Wed, 29 Apr 2026 17:17:05 GMT
Strict-Transport-Security:
- STS-XXX
apim-request-id:
- APIM-REQUEST-ID-XXX
azureml-model-session:
- AZUREML-MODEL-SESSION-XXX
skip-error-remapping:
- 'true'
x-accel-buffering:
- 'no'
x-content-type-options:
- X-CONTENT-TYPE-XXX
x-ms-client-request-id:
- X-MS-CLIENT-REQUEST-ID-XXX
x-ms-deployment-name:
- gpt-5.2-chat
x-ms-is-spilled-over:
- 'false'
x-ms-rai-invoked:
- 'true'
x-ms-region:
- X-MS-REGION-XXX
x-ratelimit-abusepenalty-active:
- 'False'
x-ratelimit-key:
- gpt-5.2-chat
x-ratelimit-limit-requests:
- X-RATELIMIT-LIMIT-REQUESTS-XXX
x-ratelimit-limit-tokens:
- X-RATELIMIT-LIMIT-TOKENS-XXX
x-ratelimit-remaining-requests:
- X-RATELIMIT-REMAINING-REQUESTS-XXX
x-ratelimit-remaining-tokens:
- X-RATELIMIT-REMAINING-TOKENS-XXX
x-ratelimit-renewalperiod-requests:
- '60'
x-ratelimit-renewalperiod-tokens:
- '60'
x-ratelimit-reset-requests:
- X-RATELIMIT-RESET-REQUESTS-XXX
x-ratelimit-reset-tokens:
- X-RATELIMIT-RESET-TOKENS-XXX
x-request-id:
- X-REQUEST-ID-XXX
status:
code: 200
message: OK
version: 1

View File

@@ -0,0 +1,128 @@
interactions:
- request:
body: '{"input":[{"role":"user","content":"Say hello in one sentence."}],"model":"gpt-5.2-chat"}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- application/json
accept-encoding:
- ACCEPT-ENCODING-XXX
authorization:
- AUTHORIZATION-XXX
connection:
- keep-alive
content-length:
- '89'
content-type:
- application/json
host:
- kkarmakar-ai-eus2.openai.azure.com
x-stainless-arch:
- X-STAINLESS-ARCH-XXX
x-stainless-async:
- async:asyncio
x-stainless-lang:
- python
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 2.32.0
x-stainless-read-timeout:
- X-STAINLESS-READ-TIMEOUT-XXX
x-stainless-retry-count:
- '0'
x-stainless-runtime:
- CPython
x-stainless-runtime-version:
- 3.13.12
method: POST
uri: https://fake-azure-endpoint.openai.azure.com/openai/v1/responses
response:
body:
string: "{\n \"id\": \"resp_02861ec017218a520069f23d21dbf88193aa91a73d63d91302\",\n
\ \"object\": \"response\",\n \"created_at\": 1777483041,\n \"status\":
\"completed\",\n \"background\": false,\n \"completed_at\": 1777483043,\n
\ \"content_filters\": [\n {\n \"blocked\": false,\n \"source_type\":
\"prompt\",\n \"content_filter_raw\": [],\n \"content_filter_results\":
{\n \"jailbreak\": {\n \"detected\": false,\n \"filtered\":
false\n }\n },\n \"content_filter_offsets\": {\n \"start_offset\":
0,\n \"end_offset\": 368,\n \"check_offset\": 0\n }\n },\n
\ {\n \"blocked\": false,\n \"source_type\": \"completion\",\n
\ \"content_filter_raw\": [],\n \"content_filter_results\": {\n \"protected_material_text\":
{\n \"detected\": false,\n \"filtered\": false\n },\n
\ \"protected_material_code\": {\n \"detected\": false,\n \"filtered\":
false\n },\n \"hate\": {\n \"filtered\": false,\n \"severity\":
\"safe\"\n },\n \"sexual\": {\n \"filtered\": false,\n
\ \"severity\": \"safe\"\n },\n \"violence\": {\n \"filtered\":
false,\n \"severity\": \"safe\"\n },\n \"self_harm\":
{\n \"filtered\": false,\n \"severity\": \"safe\"\n }\n
\ },\n \"content_filter_offsets\": {\n \"start_offset\": 0,\n
\ \"end_offset\": 44,\n \"check_offset\": 0\n }\n }\n
\ ],\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\":
null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\":
null,\n \"model\": \"gpt-5.2-chat\",\n \"output\": [\n {\n \"id\":
\"rs_02861ec017218a520069f23d2287ac819399dd23b8dd56028e\",\n \"type\":
\"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_02861ec017218a520069f23d23082c81939838ab2eebf4e89c\",\n
\ \"type\": \"message\",\n \"status\": \"completed\",\n \"content\":
[\n {\n \"type\": \"output_text\",\n \"annotations\":
[],\n \"logprobs\": [],\n \"text\": \"Hello! \\ud83d\\udc4b\"\n
\ }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\":
true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\":
null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\":
\"medium\",\n \"summary\": null\n },\n \"safety_identifier\": null,\n
\ \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n
\ \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\":
\"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\":
0,\n \"top_p\": 0.85,\n \"truncation\": \"disabled\",\n \"usage\": {\n
\ \"input_tokens\": 12,\n \"input_tokens_details\": {\n \"cached_tokens\":
0\n },\n \"output_tokens\": 21,\n \"output_tokens_details\": {\n
\ \"reasoning_tokens\": 0\n },\n \"total_tokens\": 33\n },\n \"user\":
null,\n \"metadata\": {}\n}"
headers:
Content-Length:
- '2844'
Content-Type:
- application/json
Date:
- Wed, 29 Apr 2026 17:17:25 GMT
Strict-Transport-Security:
- STS-XXX
apim-request-id:
- APIM-REQUEST-ID-XXX
skip-error-remapping:
- 'true'
x-content-type-options:
- X-CONTENT-TYPE-XXX
x-ms-client-request-id:
- X-MS-CLIENT-REQUEST-ID-XXX
x-ms-is-spilled-over:
- 'false'
x-ms-region:
- X-MS-REGION-XXX
x-ratelimit-abusepenalty-active:
- 'False'
x-ratelimit-key:
- gpt-5.2-chat
x-ratelimit-limit-requests:
- X-RATELIMIT-LIMIT-REQUESTS-XXX
x-ratelimit-limit-tokens:
- X-RATELIMIT-LIMIT-TOKENS-XXX
x-ratelimit-remaining-requests:
- X-RATELIMIT-REMAINING-REQUESTS-XXX
x-ratelimit-remaining-tokens:
- X-RATELIMIT-REMAINING-TOKENS-XXX
x-ratelimit-renewalperiod-requests:
- '60'
x-ratelimit-renewalperiod-tokens:
- '60'
x-ratelimit-reset-requests:
- X-RATELIMIT-RESET-REQUESTS-XXX
x-ratelimit-reset-tokens:
- X-RATELIMIT-RESET-TOKENS-XXX
x-request-id:
- X-REQUEST-ID-XXX
status:
code: 200
message: OK
version: 1

View File

@@ -1518,3 +1518,120 @@ def test_azure_no_detail_fields():
assert usage["completion_tokens"] == 30
assert usage["cached_prompt_tokens"] == 0
assert usage["reasoning_tokens"] == 0
def test_azure_credential_scopes_passed_to_client():
"""`credential_scopes` constructor arg flows through `_make_client_kwargs`
so the underlying ChatCompletionsClient requests tokens for the requested
audience (e.g. ``cognitiveservices.azure.com/.default``)."""
from crewai.llms.providers.azure.completion import AzureCompletion
scopes = ["https://cognitiveservices.azure.com/.default"]
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
credential_scopes=scopes,
)
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == scopes
def test_azure_credential_scopes_omitted_by_default():
"""Without explicit scopes or env var, the kwarg must not be set so the
Azure SDK chooses its own default audience."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
)
kwargs = llm._make_client_kwargs()
assert "credential_scopes" not in kwargs
def test_azure_credential_scopes_from_env_comma_separated():
"""``AZURE_CREDENTIAL_SCOPES`` accepts a comma-separated list. Whitespace
around entries is stripped; empty entries are dropped."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(
os.environ,
{
"AZURE_API_KEY": "test-key",
"AZURE_ENDPOINT": "https://test.openai.azure.com",
"AZURE_CREDENTIAL_SCOPES": " https://cognitiveservices.azure.com/.default , https://other/.default ",
},
clear=True,
):
llm = AzureCompletion(model="gpt-4")
assert llm.credential_scopes == [
"https://cognitiveservices.azure.com/.default",
"https://other/.default",
]
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == llm.credential_scopes
def test_azure_credential_scopes_constructor_overrides_env():
"""A constructor-provided ``credential_scopes`` must win over the env var,
matching how endpoint/api_key precedence works elsewhere in this provider."""
from crewai.llms.providers.azure.completion import AzureCompletion
explicit = ["https://explicit/.default"]
with patch.dict(
os.environ,
{
"AZURE_API_KEY": "test-key",
"AZURE_ENDPOINT": "https://test.openai.azure.com",
"AZURE_CREDENTIAL_SCOPES": "https://env/.default",
},
clear=True,
):
llm = AzureCompletion(model="gpt-4", credential_scopes=explicit)
assert llm.credential_scopes == explicit
def test_azure_credential_scopes_lazy_env_read():
"""When the LLM is built before ``AZURE_CREDENTIAL_SCOPES`` is exported
(e.g. constructed at module import), the lazy client builder must still
pick up the env value — same pattern as the existing api_key/endpoint
lazy reads."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
)
assert llm.credential_scopes is None
with patch.dict(
os.environ,
{"AZURE_CREDENTIAL_SCOPES": "https://late/.default"},
clear=True,
):
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == ["https://late/.default"]
assert llm.credential_scopes == ["https://late/.default"]
def test_azure_credential_scopes_in_to_config_dict():
"""Config round-trips the scopes so an LLM rebuilt from `to_config_dict`
keeps the same audience."""
from crewai.llms.providers.azure.completion import AzureCompletion
scopes = ["https://cognitiveservices.azure.com/.default"]
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
credential_scopes=scopes,
)
config = llm.to_config_dict()
assert config["credential_scopes"] == scopes

View File

@@ -0,0 +1,395 @@
"""Tests for Azure OpenAI Responses API support.
Verifies that AzureCompletion with api='responses' correctly delegates
to OpenAICompletion configured with the Azure OpenAI /openai/v1/ base URL.
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def azure_env():
"""Set Azure environment variables for tests."""
with patch.dict(
os.environ,
{
"AZURE_API_KEY": "test-azure-key",
"AZURE_ENDPOINT": "https://myresource.openai.azure.com",
},
):
yield
@pytest.fixture
def mock_openai_completion():
"""Mock OpenAICompletion to avoid real client creation.
Patches at the source module so that the dynamic import inside
_init_responses_delegate picks up the mock.
"""
instance = MagicMock()
instance.call = MagicMock(return_value="responses-result")
instance.acall = AsyncMock(return_value="async-responses-result")
instance.last_response_id = "resp_abc123"
instance.last_reasoning_items = [{"type": "reasoning"}]
instance.reset_chain = MagicMock()
instance.reset_reasoning_chain = MagicMock()
mock_cls = MagicMock(return_value=instance)
with patch(
"crewai.llms.providers.openai.completion.OpenAICompletion",
mock_cls,
):
yield mock_cls, instance
# ---------------------------------------------------------------------------
# Helper to build AzureCompletion with api="responses" while mocking imports
# ---------------------------------------------------------------------------
def _create_azure_responses(**overrides):
"""Create an AzureCompletion(api='responses').
Must be called inside a context where OpenAICompletion is already mocked
(i.e. via the ``mock_openai_completion`` fixture).
"""
from crewai.llms.providers.azure.completion import AzureCompletion
defaults = {
"model": "gpt-4o",
"api_key": "test-azure-key",
"endpoint": "https://myresource.openai.azure.com",
"api": "responses",
}
defaults.update(overrides)
return AzureCompletion(**defaults)
# ---------------------------------------------------------------------------
# Initialization tests
# ---------------------------------------------------------------------------
class TestAzureResponsesInit:
"""Test initialization with api='responses'."""
def test_default_api_is_completions(self):
"""Default api should be 'completions' (existing behaviour)."""
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
assert comp.api == "completions"
assert comp._responses_delegate is None
def test_responses_api_creates_delegate(self, mock_openai_completion):
mock_cls, instance = mock_openai_completion
comp = _create_azure_responses()
assert comp.api == "responses"
assert comp._responses_delegate is instance
mock_cls.assert_called_once()
def test_completions_clients_not_created_in_responses_mode(
self, mock_openai_completion
):
"""When api='responses', azure-ai-inference clients should not be created."""
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses()
assert comp._client is None
assert comp._async_client is None
def test_responses_base_url_from_base_endpoint(self, mock_openai_completion):
mock_cls, _ = mock_openai_completion
_create_azure_responses(
endpoint="https://myresource.openai.azure.com",
)
call_kwargs = mock_cls.call_args[1]
assert (
call_kwargs["base_url"] == "https://myresource.openai.azure.com/openai/v1/"
)
def test_responses_base_url_strips_deployment_path(self, mock_openai_completion):
"""Endpoint with /openai/deployments/... should still produce correct base_url."""
mock_cls, _ = mock_openai_completion
_create_azure_responses(
endpoint="https://myresource.openai.azure.com/openai/deployments/gpt-4o",
)
call_kwargs = mock_cls.call_args[1]
assert (
call_kwargs["base_url"] == "https://myresource.openai.azure.com/openai/v1/"
)
def test_responses_base_url_preserves_port(self, mock_openai_completion):
mock_cls, _ = mock_openai_completion
_create_azure_responses(
endpoint="https://myresource.openai.azure.com:8443/openai/deployments/gpt-4o",
)
call_kwargs = mock_cls.call_args[1]
assert (
call_kwargs["base_url"]
== "https://myresource.openai.azure.com:8443/openai/v1/"
)
def test_delegate_receives_model_and_api_key(self, mock_openai_completion):
mock_cls, _ = mock_openai_completion
_create_azure_responses(
model="gpt-4o",
api_key="my-key",
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["model"] == "gpt-4o"
assert call_kwargs["api_key"] == "my-key"
assert call_kwargs["api"] == "responses"
assert call_kwargs["provider"] == "openai"
def test_delegate_receives_optional_params(self, mock_openai_completion):
mock_cls, _ = mock_openai_completion
_create_azure_responses(
temperature=0.5,
top_p=0.9,
max_tokens=1000,
max_completion_tokens=800,
reasoning_effort="medium",
instructions="Be helpful",
store=True,
previous_response_id="resp_prev",
include=["reasoning.encrypted_content"],
builtin_tools=["web_search"],
parse_tool_outputs=True,
auto_chain=True,
auto_chain_reasoning=True,
stream=True,
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["temperature"] == 0.5
assert call_kwargs["top_p"] == 0.9
assert call_kwargs["max_tokens"] == 1000
assert call_kwargs["max_completion_tokens"] == 800
assert call_kwargs["reasoning_effort"] == "medium"
assert call_kwargs["instructions"] == "Be helpful"
assert call_kwargs["store"] is True
assert call_kwargs["previous_response_id"] == "resp_prev"
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
assert call_kwargs["builtin_tools"] == ["web_search"]
assert call_kwargs["parse_tool_outputs"] is True
assert call_kwargs["auto_chain"] is True
assert call_kwargs["auto_chain_reasoning"] is True
assert call_kwargs["stream"] is True
def test_delegate_omits_unset_optional_params(self, mock_openai_completion):
"""Params left at defaults should not be passed to the delegate."""
mock_cls, _ = mock_openai_completion
_create_azure_responses()
call_kwargs = mock_cls.call_args[1]
# These should NOT be in kwargs because they were not set
assert "temperature" not in call_kwargs
assert "reasoning_effort" not in call_kwargs
assert "instructions" not in call_kwargs
assert "store" not in call_kwargs
assert "max_completion_tokens" not in call_kwargs
# ---------------------------------------------------------------------------
# Call delegation tests (VCR cassette-based)
# ---------------------------------------------------------------------------
class TestAzureResponsesCall:
"""Test call / acall delegation to the Responses API using VCR cassettes."""
@pytest.mark.vcr()
def test_call_delegates_to_responses(self):
from crewai.llm import LLM
llm = LLM(model="azure/gpt-5.2-chat", api="responses")
result = llm.call("Say hello in one sentence.")
assert isinstance(result, str)
assert len(result) > 0
@pytest.mark.vcr()
def test_call_with_tools_delegates(self):
from crewai.llm import LLM
llm = LLM(
model="azure/gpt-5.2-chat",
api="responses",
builtin_tools=["web_search"],
)
result = llm.call("What is 2 + 2? Be brief.")
assert isinstance(result, str)
assert len(result) > 0
@pytest.mark.vcr()
def test_completions_call_unchanged(self):
"""Default api='completions' should not use the responses delegate."""
from crewai.llm import LLM
llm = LLM(model="azure/gpt-5.2-chat")
result = llm.call("Say hello in one sentence.")
assert isinstance(result, str)
assert len(result) > 0
# ---------------------------------------------------------------------------
# Delegated property & method tests
# ---------------------------------------------------------------------------
class TestAzureResponsesProperties:
"""Test properties and methods delegated to the responses delegate."""
def test_last_response_id(self, mock_openai_completion):
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses()
assert comp.last_response_id == "resp_abc123"
def test_last_response_id_none_for_completions(self):
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
assert comp.last_response_id is None
def test_last_reasoning_items(self, mock_openai_completion):
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses()
assert comp.last_reasoning_items == [{"type": "reasoning"}]
def test_reset_chain(self, mock_openai_completion):
_mock_cls, instance = mock_openai_completion
comp = _create_azure_responses()
comp.reset_chain()
instance.reset_chain.assert_called_once()
def test_reset_reasoning_chain(self, mock_openai_completion):
_mock_cls, instance = mock_openai_completion
comp = _create_azure_responses()
comp.reset_reasoning_chain()
instance.reset_reasoning_chain.assert_called_once()
def test_reset_chain_noop_for_completions(self):
"""reset_chain should not raise when delegate is None."""
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
comp.reset_chain() # should not raise
# ---------------------------------------------------------------------------
# Feature-support method tests
# ---------------------------------------------------------------------------
class TestAzureResponsesFeatures:
"""Test supports_* and config methods."""
def test_supports_function_calling_responses(self, mock_openai_completion):
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses()
assert comp.supports_function_calling() is True
def test_supports_function_calling_completions_openai_model(self):
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
assert comp.supports_function_calling() is True
def test_supports_stop_words_false_for_responses(self, mock_openai_completion):
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses(model="o4-mini")
assert comp.supports_stop_words() is False
def test_supports_stop_words_true_for_completions_gpt4(self):
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
assert comp.supports_stop_words() is True
def test_to_config_dict_includes_responses_fields(self, mock_openai_completion):
_mock_cls, _ = mock_openai_completion
comp = _create_azure_responses(
reasoning_effort="high",
instructions="Be concise",
store=True,
max_completion_tokens=500,
)
config = comp.to_config_dict()
assert config["api"] == "responses"
assert config["reasoning_effort"] == "high"
assert config["instructions"] == "Be concise"
assert config["store"] is True
assert config["max_completion_tokens"] == 500
def test_to_config_dict_omits_api_for_completions(self):
from crewai.llms.providers.azure.completion import AzureCompletion
comp = AzureCompletion(
model="gpt-4o",
api_key="key",
endpoint="https://res.openai.azure.com",
)
config = comp.to_config_dict()
assert "api" not in config
# ---------------------------------------------------------------------------
# LLM factory integration test
# ---------------------------------------------------------------------------
class TestAzureResponsesViaLLMFactory:
"""Test that the LLM factory passes api='responses' through to AzureCompletion."""
@pytest.mark.usefixtures("azure_env")
def test_llm_factory_passes_api_kwarg(self):
"""LLM(model='azure/gpt-4o', api='responses') should create AzureCompletion
with api='responses' and a delegate."""
with (
patch(
"crewai.llms.providers.openai.completion.OpenAI",
),
patch(
"crewai.llms.providers.openai.completion.AsyncOpenAI",
),
):
from crewai.llm import LLM
llm = LLM(model="azure/gpt-4o", api="responses")
from crewai.llms.providers.azure.completion import AzureCompletion
assert isinstance(llm, AzureCompletion)
assert llm.api == "responses"
assert llm._responses_delegate is not None

View File

@@ -0,0 +1,15 @@
"""Async tests for Azure OpenAI Responses API support."""
import pytest
@pytest.mark.vcr()
@pytest.mark.asyncio
async def test_acall_delegates_to_responses():
from crewai.llm import LLM
llm = LLM(model="azure/gpt-5.2-chat", api="responses")
result = await llm.acall("Say hello in one sentence.")
assert isinstance(result, str)
assert len(result) > 0

View File

@@ -0,0 +1,99 @@
"""Tests for MCPToolResolver native (non-AMP) resolution paths."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crewai.agent.core import Agent
from crewai.mcp.config import MCPServerHTTP
from crewai.mcp.tool_resolver import MCPToolResolver
@pytest.fixture
def agent():
return Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
)
@pytest.fixture
def resolver(agent):
return MCPToolResolver(agent=agent, logger=agent._logger)
@pytest.fixture
def http_config():
return MCPServerHTTP(url="https://mcp.example.com/api")
class TestResolveNativeEmptyTools:
@patch("crewai.mcp.tool_resolver.MCPClient")
def test_logs_warning_and_returns_empty_when_server_has_no_tools(
self, mock_client_class, resolver, http_config
):
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=[])
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
mock_log = MagicMock()
resolver._logger = MagicMock(log=mock_log)
tools, clients = resolver._resolve_native(http_config)
assert tools == []
assert clients == []
warning_calls = [
call for call in mock_log.call_args_list if call.args[0] == "warning"
]
assert any(
"No tools discovered from MCP server" in call.args[1]
for call in warning_calls
)
@patch("crewai.mcp.tool_resolver.MCPClient")
def test_logs_warning_when_tool_filter_removes_all_tools(
self, mock_client_class, resolver
):
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(
return_value=[{"name": "search", "description": "Search"}]
)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
config = MCPServerHTTP(
url="https://mcp.example.com/api",
tool_filter=lambda _tool: False,
)
mock_log = MagicMock()
resolver._logger = MagicMock(log=mock_log)
tools, clients = resolver._resolve_native(config)
assert tools == []
assert clients == []
warning_calls = [
call for call in mock_log.call_args_list if call.args[0] == "warning"
]
assert any(
"No tools discovered from MCP server" in call.args[1]
for call in warning_calls
)
class TestResolveNativeRuntimeError:
@patch("crewai.mcp.tool_resolver.asyncio.run")
def test_unmatched_runtime_error_is_wrapped_not_swallowed(
self, mock_asyncio_run, resolver, http_config
):
mock_asyncio_run.side_effect = RuntimeError("some other failure")
with pytest.raises(RuntimeError, match="Failed to get native MCP tools"):
resolver._resolve_native(http_config)

View File

@@ -3,6 +3,7 @@
import os
from typing import Dict, List
import pytest
from crewai.flow.flow import Flow, FlowState, listen, start
from crewai.flow.persistence import persist
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
@@ -248,3 +249,69 @@ def test_persistence_with_base_model(tmp_path):
assert message.type == "text"
assert message.content == "Hello, World!"
assert isinstance(flow.state._unwrap(), State)
def test_persist_custom_key_with_pydantic_state(tmp_path):
"""`@persist(key=...)` uses the named attribute on a Pydantic state."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class KeyedState(FlowState):
conversation_id: str = "conv-42"
message: str = ""
class KeyedFlow(Flow[KeyedState]):
@start()
@persist(persistence, key="conversation_id")
def init_step(self):
self.state.message = "hello"
flow = KeyedFlow(persistence=persistence)
flow.kickoff()
saved_state = persistence.load_state("conv-42")
assert saved_state is not None
assert saved_state["message"] == "hello"
# The default `state.id` lookup must NOT have been used as the key.
assert persistence.load_state(flow.state.id) is None
def test_persist_custom_key_with_dict_state(tmp_path):
"""`@persist(key=...)` uses the named key on a dict state."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class DictKeyedFlow(Flow[Dict[str, str]]):
initial_state = dict()
@start()
@persist(persistence, key="conversation_id")
def init_step(self):
self.state["conversation_id"] = "conv-dict-7"
self.state["message"] = "hi from dict"
flow = DictKeyedFlow(persistence=persistence)
flow.kickoff()
saved_state = persistence.load_state("conv-dict-7")
assert saved_state is not None
assert saved_state["message"] == "hi from dict"
def test_persist_custom_key_missing_raises(tmp_path):
"""A missing/falsy custom key must raise a clear ValueError."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class MissingKeyFlow(Flow[Dict[str, str]]):
initial_state = dict()
@start()
@persist(persistence, key="conversation_id")
def init_step(self):
# Intentionally do NOT set "conversation_id" on state.
self.state["message"] = "no key here"
flow = MissingKeyFlow(persistence=persistence)
with pytest.raises(ValueError, match="conversation_id"):
flow.kickoff()

View File

@@ -0,0 +1,259 @@
"""Tests for tracing disabled message suppression (issue #5665).
Verifies that:
- Users who explicitly declined tracing are NOT nagged with the message.
- The CREWAI_SUPPRESS_TRACING_MESSAGES env var suppresses the message.
- The message is shown only when tracing is disabled and user hasn't declined.
"""
from unittest.mock import MagicMock, patch
import pytest
from crewai.events.listeners.tracing.utils import (
set_suppress_tracing_messages,
should_suppress_tracing_messages,
)
class TestShouldSuppressTracingMessages:
"""Tests for the should_suppress_tracing_messages utility function."""
def test_suppress_false_by_default(self):
"""By default, messages should NOT be suppressed."""
token = set_suppress_tracing_messages(False)
try:
assert should_suppress_tracing_messages() is False
finally:
from crewai.events.listeners.tracing.utils import (
_suppress_tracing_messages,
)
_suppress_tracing_messages.reset(token)
def test_suppress_via_context_var(self):
"""Setting the context var should suppress messages."""
token = set_suppress_tracing_messages(True)
try:
assert should_suppress_tracing_messages() is True
finally:
from crewai.events.listeners.tracing.utils import (
_suppress_tracing_messages,
)
_suppress_tracing_messages.reset(token)
@pytest.mark.parametrize("env_value", ["true", "True", "TRUE", "1", "yes", "YES"])
def test_suppress_via_env_var(self, env_value, monkeypatch):
"""CREWAI_SUPPRESS_TRACING_MESSAGES env var should suppress messages."""
token = set_suppress_tracing_messages(False)
try:
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", env_value)
assert should_suppress_tracing_messages() is True
finally:
from crewai.events.listeners.tracing.utils import (
_suppress_tracing_messages,
)
_suppress_tracing_messages.reset(token)
@pytest.mark.parametrize("env_value", ["false", "False", "0", "no", ""])
def test_no_suppress_with_falsy_env_var(self, env_value, monkeypatch):
"""Falsy values for the env var should NOT suppress messages."""
token = set_suppress_tracing_messages(False)
try:
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", env_value)
assert should_suppress_tracing_messages() is False
finally:
from crewai.events.listeners.tracing.utils import (
_suppress_tracing_messages,
)
_suppress_tracing_messages.reset(token)
def test_context_var_takes_precedence_over_env(self, monkeypatch):
"""Context var set to True should suppress even if env var is false."""
token = set_suppress_tracing_messages(True)
try:
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", "false")
assert should_suppress_tracing_messages() is True
finally:
from crewai.events.listeners.tracing.utils import (
_suppress_tracing_messages,
)
_suppress_tracing_messages.reset(token)
class TestShowTracingDisabledMessage:
"""Tests that _show_tracing_disabled_message does not nag declined users."""
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={"first_execution_done": True, "trace_consent": False},
)
def test_crew_no_message_when_user_declined(self, mock_load):
"""Crew._show_tracing_disabled_message should not print when user declined."""
from crewai.crew import Crew
with patch("crewai.crew.Console") as MockConsole:
Crew._show_tracing_disabled_message()
MockConsole.return_value.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={"first_execution_done": True, "trace_consent": False},
)
def test_flow_no_message_when_user_declined(self, mock_load):
"""Flow._show_tracing_disabled_message should not print when user declined."""
from crewai.flow.flow import Flow
with patch("crewai.flow.flow.Console") as MockConsole:
Flow._show_tracing_disabled_message()
MockConsole.return_value.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={"first_execution_done": True, "trace_consent": False},
)
def test_trace_listener_no_message_when_user_declined(self, mock_load):
"""TraceCollectionListener._show_tracing_disabled_message should not print when user declined."""
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
listener = TraceCollectionListener.__new__(TraceCollectionListener)
with patch("rich.console.Console") as MockConsole:
listener._show_tracing_disabled_message()
MockConsole.return_value.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
def test_crew_shows_message_when_user_has_not_decided(self, mock_load):
"""Crew._show_tracing_disabled_message should print when user hasn't decided yet."""
from crewai.crew import Crew
with patch("crewai.crew.Console") as MockConsole:
mock_console_instance = MockConsole.return_value
Crew._show_tracing_disabled_message()
mock_console_instance.print.assert_called_once()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
def test_crew_no_message_when_suppress_env_set(self, mock_load, monkeypatch):
"""Crew._show_tracing_disabled_message should not print when env var suppresses."""
from crewai.crew import Crew
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", "true")
with patch("crewai.crew.Console") as MockConsole:
Crew._show_tracing_disabled_message()
MockConsole.return_value.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
def test_flow_no_message_when_suppress_env_set(self, mock_load, monkeypatch):
"""Flow._show_tracing_disabled_message should not print when env var suppresses."""
from crewai.flow.flow import Flow
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", "true")
with patch("crewai.flow.flow.Console") as MockConsole:
Flow._show_tracing_disabled_message()
MockConsole.return_value.print.assert_not_called()
class TestConsoleFormatterTracingMessage:
"""Tests for console_formatter._show_tracing_disabled_message_if_needed."""
def _make_formatter(self):
from crewai.events.utils.console_formatter import ConsoleFormatter
formatter = ConsoleFormatter.__new__(ConsoleFormatter)
formatter.console = MagicMock()
formatter.verbose = True
return formatter
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={"first_execution_done": True, "trace_consent": False},
)
def test_no_message_when_user_declined(self, mock_load):
"""Console formatter should not show the message when user declined tracing."""
formatter = self._make_formatter()
with patch(
"crewai.events.listeners.tracing.trace_listener.TraceCollectionListener"
) as mock_listener_cls:
mock_listener_cls._instance = None
formatter._show_tracing_disabled_message_if_needed()
formatter.console.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
def test_no_message_when_suppress_env_set(self, mock_load, monkeypatch):
"""Console formatter should not show the message when env var is set."""
monkeypatch.setenv("CREWAI_SUPPRESS_TRACING_MESSAGES", "true")
formatter = self._make_formatter()
formatter._show_tracing_disabled_message_if_needed()
formatter.console.print.assert_not_called()
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
@patch(
"crewai.events.listeners.tracing.utils.is_tracing_enabled_in_context",
return_value=False,
)
def test_message_shown_when_tracing_disabled_and_not_declined(
self, mock_tracing_ctx, mock_load
):
"""Console formatter should show the message when tracing disabled and user hasn't declined."""
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
formatter = self._make_formatter()
mock_instance = MagicMock()
mock_instance.first_time_handler.is_first_time = False
original_instance = TraceCollectionListener._instance
try:
TraceCollectionListener._instance = mock_instance # type: ignore[misc]
formatter._show_tracing_disabled_message_if_needed()
formatter.console.print.assert_called_once()
finally:
TraceCollectionListener._instance = original_instance # type: ignore[misc]
@patch(
"crewai.events.listeners.tracing.utils._load_user_data",
return_value={},
)
@patch(
"crewai.events.listeners.tracing.utils.is_tracing_enabled_in_context",
return_value=True,
)
def test_no_message_when_tracing_enabled(self, mock_tracing_ctx, mock_load):
"""Console formatter should not show the message when tracing is enabled."""
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
formatter = self._make_formatter()
mock_instance = MagicMock()
mock_instance.first_time_handler.is_first_time = False
original_instance = TraceCollectionListener._instance
try:
TraceCollectionListener._instance = mock_instance # type: ignore[misc]
formatter._show_tracing_disabled_message_if_needed()
formatter.console.print.assert_not_called()
finally:
TraceCollectionListener._instance = original_instance # type: ignore[misc]

View File

@@ -940,6 +940,8 @@ def test_internal_instructor_real_unsupported_provider() -> None:
mock_llm.is_litellm = False
mock_llm.model = "unsupported-model"
mock_llm.provider = "unsupported"
mock_llm.base_url = None
mock_llm.api_key = None
# This should raise a ConfigurationError from the real instructor library
with pytest.raises(Exception) as exc_info:
@@ -952,3 +954,45 @@ def test_internal_instructor_real_unsupported_provider() -> None:
# Verify it's a configuration error about unsupported provider
assert "Unsupported provider" in str(exc_info.value) or "unsupported" in str(exc_info.value).lower()
def test_internal_instructor_forwards_base_url_and_api_key() -> None:
"""base_url and api_key on the LLM must flow into instructor.from_provider."""
from crewai.utilities.internal_instructor import InternalInstructor
mock_llm = Mock()
mock_llm.is_litellm = False
mock_llm.model = "gpt-4o"
mock_llm.provider = "openai"
mock_llm.base_url = "https://custom.example.com/v1"
mock_llm.api_key = "sk-custom"
with patch("instructor.from_provider") as mock_from_provider:
mock_from_provider.return_value = Mock()
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
mock_from_provider.assert_called_once_with(
"openai/gpt-4o",
base_url="https://custom.example.com/v1",
api_key="sk-custom",
)
def test_internal_instructor_omits_unset_base_url_and_api_key() -> None:
"""When base_url/api_key are None, they must not be passed to from_provider."""
from crewai.utilities.internal_instructor import InternalInstructor
mock_llm = Mock()
mock_llm.is_litellm = False
mock_llm.model = "gpt-4o"
mock_llm.provider = "openai"
mock_llm.base_url = None
mock_llm.api_key = None
with patch("instructor.from_provider") as mock_from_provider:
mock_from_provider.return_value = Mock()
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
mock_from_provider.assert_called_once_with("openai/gpt-4o")