Task 76 — ResilientLLMClient per-call provider/model
Phase: 5
Initiative: 45
Status: done
Owner: —
Slug: resilient-llm-per-call-provider
Goal
ResilientLLMClient.ProviderName и ModelName (LLM/ResilientLLMClient.cs:43-45) — plain public string instance properties с private set. Они записываются из CompleteMainAsync (:151-152), InvokeRoleAsync (:211-212,239-240), и streaming paths (:290-291). Concurrent запроси race на эти записи — last-writer-wins, и reader видит значение от другого запроса. Это портит tracing/metrics/audit (неправильный provider/model в логах и OTel tags).
Acceptance criteria
Sub-tasks
- Убрать записи
ProviderName/ModelNameизResilientLLMClient.CompleteMainAsync,InvokeRoleAsync,StreamMainAsync,StreamSingleAsync— instance properties остаются readonly (set только в ctor иRebuildChain). LlmResponseуже содержитProvider/Model(ChatModels.cs:24-28) — propagate per-call provider/model через response, не через shared instance state.CompleteMainAsync(:143-160) — OTelActivitytags и metrics используют локальные переменные (client.ProviderName/client.ModelName) иresp.Provider/resp.Model(per-call).InvokeRoleAsync— локальныеclient.ProviderName/client.ModelNameдля логов/OTel; возвращаетrespс правильнымProvider/Model.- Streaming path (
:290-291) — provider/model вычитываются из concrete client локально, логируются и кладутся в OTelActivitytags, не в instance fields.IAsyncEnumerable<string>contract не ломаем. - Найти всех callers которые читают
ResilientLLMClient.ProviderName/ModelName—grepчист: ни одного читателя вне самогоResilientLLMClient.csи тестовLlmClientFactoryTests(тесты читают с concrete client, не с wrapper). AgentCoreи другие callers — используютresponse.Provider/response.Model(черезLlmResponse, не instance properties wrapper-а).ILLMClientinterface —ProviderName/ModelNameостаются как read-only instance properties на concrete clients (YandexGPTClient,LocalLLMClient,LMStudioClient); наResilientLLMClientони теперь отражают настроенный primary (set в ctor/RebuildChain), не per-call.- Unit-тест: 10 параллельных
CompleteAsyncчерезResilientLLMClientс разными fallback chains — каждый response содержит правильный provider/model (не застрявший от другого запроса). - Unit-тест: OTel
Activitytags per-call содержат правильный provider (mockIOtelService). dotnet build+dotnet testpass.
Scope / Likely files
src/agent/LLM/ResilientLLMClient.cs, src/agent/LLM/ILLMClient.cs, src/agent/LLM/LlmResponse.cs (or Models.cs), src/agent/Agent/AgentCore.cs, src/agent/Observability/OtelMetrics.cs
Dependencies
- блокирует / опирается на: task_004 — multi-provider-llm
- блокирует / опирается на: task_013 — opentelemetry
Risks / Rollback
Interface change (LlmResponse получает новые поля) — backward-compatible если поля optional/default. Rollback: вернуть instance properties (но race останется).
Implementation notes
ResilientLLMClient.ProviderName/ModelNameтеперь derived from configuredLlmConfig.Provider(set только в ctor иReload/RebuildChain). Это устраняет per-call race, но также означает что wrapper больше не трекает фактически ответивший провайдер в instance state. Эта информация теперь доступна черезLlmResponse.Provider/LlmResponse.Model, что и хочется.- OTel tags (
llm.provider,llm.model) устанавливаются per-call с использованиемclient.ProviderName/client.ModelName(concrete client) и обновляются фактическими значениями изresp.Provider/resp.Modelпосле успешного вызова. - Стрим:
IAsyncEnumerable<string>interface contract сохранён; provider/model попадают в OTelActivity(создаётся при старте стрима и закрывается при завершении) и вILoggerна первом chunk-е. Никакого shared state. LlmResponse.Provider/Modelуже корректно заполняются вChatClientLLMClient.CompleteAsync(:35) — используем их как source of truth.
Validation
dotnet build src/agent/Hercules.csproj— passes.dotnet test tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj --filter "FullyQualifiedName~ResilientLLMClient"— passes (новые тесты + старые retry-тесты).dotnet test tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj --filter "FullyQualifiedName~LLM|FullyQualifiedName~AgentCore"— 241/241 passed.- Полный прогон: 1783 passed, 10 failed (все failures pre-existing — OtelServiceTests, NumericValidatorTests, BudgetGuardTests, WasmToolTests, RedisTaskQueueTests — environmental, не связаны с правкой; подтверждено
git stash+ re-run).
Commit
8169d87—fix(roadmap): complete task 076 - per-call provider/model in LlmResponse
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md