diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 303a6ec2..a4f738fb 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -154,6 +154,7 @@ async def process_representation_tasks_batch( enable_retry=True, retry_attempts=3, trace_name="minimal_deriver", + request_metadata={"namespace": settings.NAMESPACE}, telemetry=LLMTelemetryContext( workspace_name=latest_message.workspace_name, call_purpose=CallPurpose.DERIVER_REPRESENTATION.value, diff --git a/src/llm/api.py b/src/llm/api.py index 9c9a628d..60aefc40 100644 --- a/src/llm/api.py +++ b/src/llm/api.py @@ -75,6 +75,7 @@ async def honcho_llm_call( trace_name: str | None = None, iteration_callback: IterationCallback | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> HonchoLLMCallResponse[M]: ... @@ -105,6 +106,7 @@ async def honcho_llm_call( trace_name: str | None = None, iteration_callback: IterationCallback | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> HonchoLLMCallResponse[str]: ... @@ -135,6 +137,7 @@ async def honcho_llm_call( trace_name: str | None = None, iteration_callback: IterationCallback | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ... @@ -165,6 +168,7 @@ async def honcho_llm_call( trace_name: str | None = None, iteration_callback: IterationCallback | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> ( HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] @@ -243,6 +247,7 @@ async def honcho_llm_call( selected_config=plan.selected_config, plan=plan, telemetry=telemetry, + request_metadata=request_metadata, ) return await honcho_llm_call_inner( plan.provider, @@ -263,6 +268,7 @@ async def honcho_llm_call( selected_config=plan.selected_config, plan=plan, telemetry=telemetry, + request_metadata=request_metadata, ) decorated = _call_with_provider_selection @@ -373,6 +379,7 @@ async def honcho_llm_call( plan=plan, telemetry=telemetry, messages=captured_messages, + request_metadata=request_metadata, ) return await honcho_llm_call_inner( plan.provider, @@ -394,6 +401,7 @@ async def honcho_llm_call( plan=plan, telemetry=telemetry, messages=captured_messages, + request_metadata=request_metadata, ) wrapped = _toolless_call diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index b2d82d91..7d68a7d1 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -322,6 +322,8 @@ class OpenAIBackend: ): if key in extra_params: params[key] = extra_params[key] + if "metadata" in extra_params: + params["metadata"] = extra_params["metadata"] return params def _normalize_response( diff --git a/src/llm/executor.py b/src/llm/executor.py index 87db8dd0..3e45ce50 100644 --- a/src/llm/executor.py +++ b/src/llm/executor.py @@ -210,6 +210,7 @@ async def honcho_llm_call_inner( selected_config: ModelConfig | None = None, plan: AttemptPlan | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> HonchoLLMCallResponse[M]: ... @@ -234,6 +235,7 @@ async def honcho_llm_call_inner( selected_config: ModelConfig | None = None, plan: AttemptPlan | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> HonchoLLMCallResponse[str]: ... @@ -258,6 +260,7 @@ async def honcho_llm_call_inner( selected_config: ModelConfig | None = None, plan: AttemptPlan | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... @@ -281,6 +284,7 @@ async def honcho_llm_call_inner( selected_config: ModelConfig | None = None, plan: AttemptPlan | None = None, telemetry: LLMTelemetryContext | None = None, + request_metadata: dict[str, str] | None = None, ) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: """One backend call. No retry, no fallback, no tool loop. @@ -318,6 +322,8 @@ async def honcho_llm_call_inner( # knobs — they pass through extra_params. execute_completion merges # build_config_extra_params(effective_config) on top for top_p/seed/etc. call_extras: dict[str, Any] = {"json_mode": json_mode, "verbosity": verbosity} + if request_metadata is not None: + call_extras["metadata"] = request_metadata if stream: # Stream path: setup must run inside the awaited coroutine so it diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index a2058bf9..e67c6732 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -64,6 +64,7 @@ class TestDeriverProcessing: == expected_config.thinking_budget_tokens ) assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences + assert kwargs["request_metadata"] == {"namespace": settings.NAMESPACE} assert "llm_settings" not in kwargs async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt( diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 695b12cd..0419be69 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -227,6 +227,44 @@ async def test_openai_backend_skips_extra_body_when_thinking_budget_zero() -> No assert "extra_body" not in call +@pytest.mark.asyncio +async def test_openai_backend_passes_request_metadata() -> None: + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + extra_params={"metadata": {"namespace": "honcho"}}, + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["metadata"] == {"namespace": "honcho"} + + @pytest.mark.asyncio async def test_openai_backend_converts_anthropic_style_tools() -> None: client = Mock()