Refactor clients.py to add modern features and more flexible configuration (#459)
* fix: Add JSON repair for truncated LLM responses across all providers and Gemini thinking budget support
LengthFinishReasonError from OpenAI-compatible providers (custom, openai, groq) was crashing the deriver
with 14k+ occurrences in production. The vLLM path already had repair logic but it was gated on
provider=="vllm", unreachable when routing through litellm as a custom provider.
- Extract shared _repair_response_model_json() helper for all providers
- Catch LengthFinishReasonError in OpenAI/custom parse() path and repair truncated JSON
- Add repair fallback to Anthropic and Gemini response_model paths
- Add repair fallback to Groq response_model path
- Pass thinking_budget_tokens to Gemini 2.5 models via thinking_config
- Add 14 tests covering repair paths for all providers and Gemini thinking budget
Fixes HONCHO-YC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: live llm integration tests
* feat: Consistent Model Config Protocol
* fix: migrate the remaining app callers off the legacy llm_settings path
* fix: Docs and regression tests
* fix: refactor llm runtime path to model-config-only API
* fix: refactor config to nested model-config source of truth
* fix: refactor llm streaming and tool dispatch through backends
* fix: cut over llm config to nested model_config only
* fix: collapse vllm and custom into openai_compatible transport
* feat: refactor llm config to explicit transports and bare model ids
* feat: (embed) Add configurability for embedding model
* fix: tests for embedding provider
* fix: Address Review Comments
* fix: (llm) remove Groq backend and per-vendor base URLs
* chore: move llm tests
* fix: (llm) address review findings — config regressions, backend bugs, dead code
* fix: address backend end silly errors
* chore: (docs) update configuration and self-hosting guides
* chore: fix tests
* fix: address code rabbit comments
* fix: add validation to the dream settings
* fix: further address code rabbit comments
* fix: Address Code Rabbit Comments
* fix: Another round of code rabbit
* fix: Address Code Rabbit Nits
* fix: tests
* refactor: rename thinking validator to reflect transport scope
_validate_anthropic_thinking_minimum only enforces the >=1024 rule for
Anthropic and no-ops for other transports, so the name was misleading
now that it's shared across ConfiguredModelSettings, FallbackModelSettings,
and ModelConfig. Renamed to _validate_thinking_constraints with a docstring
clarifying per-transport behavior. No logic change.
* fix(config): drop transport-specific thinking params when env override changes transport
_fill_defaults_for_nested_field previously preserved the default MODEL_CONFIG's
thinking_budget_tokens/thinking_effort across a transport override. This leaked
Gemini-family defaults (e.g. thinking_budget_tokens=1024) into OpenAI-transport
overrides, and the OpenAI backend then correctly rejected the unsupported param
at call time (OpenAI uses reasoning.effort, not a token budget).
The helper now strips thinking_budget_tokens and thinking_effort from the
default dict when the env override supplies a transport different from the
default's. Explicit thinking params in the override are preserved.
* fix(config): apply thinking-param strip to dialectic level merge too
DialecticSettings._merge_level_defaults does its own inline MODEL_CONFIG
merge (parallel to _fill_defaults_for_nested_field), so the previous fix
missed dialectic-level overrides. E.g. flipping
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT from gemini (default)
to openai still leaked the default thinking_budget_tokens=0 into the
openai config, which the OpenAI backend then rejected at call time.
The level-merge path now applies the same 'strip transport-specific
thinking params when transport changes' rule as the generic helper.
Added a regression test exercising the merge validator directly.
* refactor(llm): wire ModelConfig knobs through, prune clients.py migration leftovers
Three connected fixes to finish carving the LLM stack out of src/utils/clients.py
and into src/llm/:
1. Propagate ModelConfig tuning knobs into backend calls.
honcho_llm_call_inner built extra_params from only {json_mode, verbosity},
silently dropping top_p, top_k, frequency_penalty, presence_penalty, seed,
and operator-supplied provider_params from any ModelConfig. Thread the
selected config through ProviderSelection and merge
build_config_extra_params(selected_config) into extra_params; per-call
kwargs still win over provider_params defaults. Makes
_build_config_extra_params public as build_config_extra_params so
clients.py and request_builder.py share one translation. Adds
TestModelConfigExtraParamsPropagation covering OpenAI/Anthropic knob
propagation, provider_params passthrough, and per-call override
precedence.
2. Drop dead extract_openai_* duplicates in clients.py.
extract_openai_reasoning_content, extract_openai_reasoning_details, and
extract_openai_cache_tokens had no callers outside their own definitions
— the live implementations live in src/llm/backends/openai.py. -103
lines from clients.py.
3. Unify on ModelTransport, delete SupportedProviders.
The "google" vs "gemini" split forced a _provider_for_model_config
translation shim in two places. Replace all SupportedProviders usages
with ModelTransport, rename CLIENTS["google"] → CLIENTS["gemini"],
update provider branches + LLMError labels + reasoning-trace entries
accordingly. Trace JSONL now writes "provider": "gemini" instead of
"google" — consistent with the broader env-var rename cutover.
Also tidies up pre-existing basedpyright findings in tests/llm/test_model_config.py
(pydantic before-validator dict inputs + descriptor-proxy call).
ruff: clean. basedpyright: 0 errors, 0 warnings. Tests: 153/153 pass across
tests/utils/test_clients.py, tests/utils/test_length_finish_reason.py,
tests/llm/, tests/dialectic/, tests/deriver/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(llm): finish the src/utils/clients.py → src/llm/ migration
honcho_llm_call_inner now delegates to request_builder.execute_completion
and execute_stream instead of re-implementing backend call scaffolding
inline. The new _effective_config_for_call helper carries per-call kwargs
(temperature, stop_seqs, thinking_budget_tokens, reasoning_effort) onto
the selected ModelConfig — or synthesizes a minimal config for the
test-only callers that pass provider+model directly. max_output_tokens
is zeroed on the effective config to preserve the current
"per-call max_tokens wins" semantic; honoring ModelConfig.max_output_tokens
is a separable correctness concern.
Side effect of routing through the new path: ConfiguredModelSettings'
thinking_budget_tokens validator now fires on synthesized configs.
test_anthropic_thinking_budget was asserting that a sub-1024 budget
propagated to Anthropic — bumped to 1024 to match what Anthropic actually
accepts.
Unified client construction. Promoted the cached client factories in
src/llm/__init__.py (get_anthropic_client, get_openai_client,
get_gemini_client, get_{anthropic,openai,gemini}_override_client) to
public API and added them to __all__. Promoted
credentials._default_transport_api_key → default_transport_api_key.
Deleted the duplicate _build_client and _default_credentials_for_provider
from clients.py; _client_for_model_config now falls through to the
public factories. CLIENTS dict and _get_backend_for_provider stay as the
mockable seam for the ~50 patch.dict(CLIENTS, {...}) test call sites.
Wired operator-configurable Gemini cached-content reuse end-to-end.
PromptCachePolicy moved from src/llm/caching.py into src/config.py so
ModelConfig can reference it as a field without a circular import;
caching.py re-exports the name for existing imports. Added
cache_policy: PromptCachePolicy | None on ConfiguredModelSettings,
FallbackModelSettings, ResolvedFallbackConfig, and ModelConfig.
resolve_model_config, _resolve_fallback_config, and
_select_model_config_for_attempt copy the field through.
honcho_llm_call_inner passes effective_config.cache_policy into
execute_completion / execute_stream, so operators opt in via
e.g. DERIVER_MODEL_CONFIG__CACHE_POLICY__MODE=gemini_cached_content
and the selection actually fires instead of sitting on a dead path.
New regression test test_cache_policy_reaches_gemini_backend asserts the
PromptCachePolicy object reaches the Gemini backend's extra_params.
ruff + basedpyright: clean. Tests: 154/154 pass across
tests/utils/test_clients.py, tests/utils/test_length_finish_reason.py,
tests/llm/, tests/dialectic/, tests/deriver/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(llm): move all LLM orchestration into src/llm/ and delete clients.py
The 1624-line src/utils/clients.py has been carved up into focused modules
under src/llm/ and deleted. There is now one golden path for LLM
orchestration and no dual entrypoint.
New module layout:
src/llm/
__init__.py thin stable re-export surface
api.py public honcho_llm_call with retry + fallback + tool
loop delegation
executor.py honcho_llm_call_inner (single-call executor); bridges
to request_builder.execute_completion / execute_stream
tool_loop.py execute_tool_loop + stream_final_response, plus
assistant-tool-message and tool-result formatting
runtime.py AttemptPlan dataclass (replaces the loose
ProviderSelection NamedTuple), effective_config_for_call,
plan_attempt, per-retry temperature bump, attempt
ContextVar
registry.py single owner of CLIENTS dict + cached default and
override SDK-client factories + backend/history-adapter
selection + high-level get_backend(config)
conversation.py count_message_tokens, tool-aware message grouping,
truncate_messages_to_fit
types.py HonchoLLMCallResponse, HonchoLLMCallStreamChunk,
StreamingResponseWithMetadata, IterationData,
IterationCallback, ReasoningEffortType, VerbosityType,
ProviderClient
request_builder.py low-level request assembly (ModelConfig → backend
complete/stream); no longer owns credential resolution
credentials.py default_transport_api_key, resolve_credentials
caching.py gemini_cache_store; re-exports PromptCachePolicy
from src.config
backend.py Protocol + normalized result types
history_adapters.py provider-specific assistant/tool message shapes
structured_output.py
backends/ AnthropicBackend, OpenAIBackend, GeminiBackend
handle_streaming_response had no production callers; it is deleted. The
three tests that used it now drive honcho_llm_call_inner(stream=True,
client_override=...) directly, which exercises the same code path the
public API uses.
Dead credential passthrough removed. The ProviderBackend Protocol and
all three concrete backends no longer accept api_key / api_base — those
are baked into the underlying SDK client at registry construction time
and were being del'd everywhere they appeared. request_builder also
stops resolving and forwarding them.
Client construction is unified. The cached default-client factories
(get_anthropic_client, get_openai_client, get_gemini_client) and override
factories (get_*_override_client) are promoted to public API; the
module-level CLIENTS dict populates from them and remains the
patch.dict(CLIENTS, {...}) mocking seam tests rely on. Old duplicate
helpers (_build_client, _default_credentials_for_provider) are gone.
default_transport_api_key is promoted to public.
Application imports now come from src.llm (dreamer, dialectic, deriver,
summarizer, telemetry-adjacent tests). No code imports from
src.utils.clients anywhere in the repo.
ruff: clean. basedpyright: 0 errors, 0 warnings. Tests: 1013/1013 pass
across the entire non-infra test suite (excluding tests/unified,
tests/bench, tests/live_llm, tests/alembic).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(llm): sanitize tool schemas for Gemini's function_declarations validator
Gemini's native-transport function-declarations validator accepts a narrow
subset of JSON-Schema / OpenAPI: type, format, description, nullable, enum,
properties, required, items, minItems, maxItems, minimum, maximum, title.
Anything else — additionalProperties, allOf, if/then/else, $ref, anyOf,
oneOf, $defs, patternProperties — triggers an INVALID_ARGUMENT 400 at call
time.
Our agent tool schemas in src/utils/agent_tools.py use several of those
(additionalProperties: false, allOf + if/then conditionals) because they
were authored for OpenAI strict-mode + Anthropic, which need the richer
vocabulary. GeminiBackend._convert_tools was passing them straight through.
Add _sanitize_schema(): walks the parameters tree and drops unsupported
keywords while preserving semantics for the keywords that hold user data
(properties maps field-name → sub-schema; required / enum are lists of
literals; items is a single sub-schema). Other backends are untouched and
continue to receive the full strict schemas.
Regression tests:
- test_gemini_sanitize_schema_strips_unsupported_keywords: confirms
additionalProperties, allOf + if/then, and $defs are stripped at nested
levels while legitimate fields survive.
- test_gemini_convert_tools_sanitizes_parameters_schema: end-to-end
_convert_tools output has no forbidden keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: fix tool calling syntax for gemini
* refactor(llm): normalize defaults, widen OpenAI reasoning-model routing
* chore: fix test
* fix(llm): address post-migration review feedback
* fix(llm): gemini robustness + dreamer specialist ergonomics
* chore: addres review comments
* chore: (docs) unrelease changelog addition
* chore: (docs) merge commit changes
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Erosika <eri@plasticlabs.ai>
This commit is contained in:
parent
96765263f4
commit
b65d03d297
148
.env.template
148
.env.template
|
|
@ -15,8 +15,13 @@ LOG_LEVEL=INFO
|
|||
|
||||
# Embedding settings
|
||||
# EMBED_MESSAGES=true
|
||||
# MAX_EMBEDDING_TOKENS=8192
|
||||
# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
|
||||
# EMBEDDING_VECTOR_DIMENSIONS=1536
|
||||
# EMBEDDING_MAX_INPUT_TOKENS=8192
|
||||
# EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
|
||||
# EMBEDDING_MODEL_CONFIG__TRANSPORT=openai
|
||||
# EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small
|
||||
# EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=
|
||||
# EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=
|
||||
|
||||
# LANGFUSE_HOST=
|
||||
# LANGFUSE_PUBLIC_KEY=
|
||||
|
|
@ -62,55 +67,59 @@ AUTH_USE_AUTH=false
|
|||
# Honcho uses LLMs for memory extraction, summarization, dialectic chat, and
|
||||
# dream consolidation. The server will fail to start without a provider configured.
|
||||
#
|
||||
# Quick start: uncomment the two lines below, set your endpoint and API key,
|
||||
# then uncomment the provider/model lines in each feature section below.
|
||||
# Any OpenAI-compatible endpoint works (OpenRouter, Together, Fireworks, etc.).
|
||||
# Quick start: set LLM_OPENAI_API_KEY below to use the built-in defaults.
|
||||
# Text-generation features default to transport = "openai" and
|
||||
# model = "gpt-5.4-mini". Embeddings default to transport = "openai" and
|
||||
# model = "text-embedding-3-small". For OpenAI-compatible proxies
|
||||
# (OpenRouter, Together, Fireworks, vLLM, Ollama, LiteLLM), override
|
||||
# MODEL_CONFIG__MODEL and MODEL_CONFIG__OVERRIDES__BASE_URL on each feature
|
||||
# section you want to route through that endpoint.
|
||||
# Models must support tool calling (function calling).
|
||||
#
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=your-api-key-here
|
||||
# Supported transports: openai, anthropic, gemini
|
||||
# Each transport picks up its API key from the corresponding LLM_*_API_KEY.
|
||||
# Base URLs are set per-module via MODEL_CONFIG__OVERRIDES__BASE_URL.
|
||||
#
|
||||
# Provider options for each feature: custom, vllm, google, anthropic, openai, groq
|
||||
# "custom" routes through the OpenAI-compatible endpoint above.
|
||||
# Model name format depends on your provider (e.g., OpenRouter: vendor/model-name).
|
||||
#
|
||||
# ---- Alternative: vLLM self-hosted ------------------------------------------
|
||||
# LLM_VLLM_BASE_URL=http://localhost:8000/v1
|
||||
# LLM_VLLM_API_KEY=not-needed
|
||||
#
|
||||
# ---- Alternative: direct vendor keys (no endpoint needed) -------------------
|
||||
# LLM_GEMINI_API_KEY=
|
||||
LLM_OPENAI_API_KEY=your-api-key-here
|
||||
# LLM_ANTHROPIC_API_KEY=
|
||||
# LLM_OPENAI_API_KEY=
|
||||
# LLM_GROQ_API_KEY=
|
||||
#
|
||||
# ---- General LLM settings ---------------------------------------------------
|
||||
# Embedding provider — defaults to openai (requires LLM_OPENAI_API_KEY).
|
||||
# Set to openrouter to route embeddings through your custom endpoint instead.
|
||||
LLM_EMBEDDING_PROVIDER=openrouter
|
||||
# LLM_GEMINI_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# LLM Configuration
|
||||
# =============================================================================
|
||||
# Global LLM settings
|
||||
# LLM_DEFAULT_MAX_TOKENS=2500
|
||||
# LLM_MAX_TOOL_OUTPUT_CHARS=10000
|
||||
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000
|
||||
# LLM_MAX_TOOL_OUTPUT_CHARS=10000 # Max chars for tool output (~2500 tokens)
|
||||
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results
|
||||
|
||||
# =============================================================================
|
||||
# Deriver (Background Worker)
|
||||
# =============================================================================
|
||||
# DERIVER_ENABLED=true
|
||||
DERIVER_PROVIDER=custom
|
||||
DERIVER_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# DERIVER_THINKING_BUDGET_TOKENS=1024 # gt=0 required; omit for non-thinking models
|
||||
# Defaults:
|
||||
# DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
# DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# Optional overrides:
|
||||
# DERIVER_MODEL_CONFIG__MODEL=your-model-here
|
||||
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DERIVER_WORKERS=1
|
||||
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
|
||||
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000
|
||||
# DERIVER_TEMPERATURE=
|
||||
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
|
||||
# DERIVER_MODEL_CONFIG__TEMPERATURE=
|
||||
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only
|
||||
# DERIVER_DEDUPLICATE=true
|
||||
# DERIVER_MAX_OUTPUT_TOKENS=4096
|
||||
# DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096
|
||||
# DERIVER_LOG_OBSERVATIONS=false
|
||||
# DERIVER_MAX_INPUT_TOKENS=23000
|
||||
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
|
||||
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
|
||||
# DERIVER_FLUSH_ENABLED=false
|
||||
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT=
|
||||
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=
|
||||
# DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=
|
||||
|
||||
# =============================================================================
|
||||
# Peer Card
|
||||
|
|
@ -125,58 +134,79 @@ DERIVER_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
|||
# DIALECTIC_HISTORY_TOKEN_LIMIT=8192
|
||||
# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=4096
|
||||
#
|
||||
# Per-level provider, model, and tuning:
|
||||
DIALECTIC_LEVELS__minimal__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__minimal__MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
|
||||
# Per-level settings (reasoning_level parameter in API)
|
||||
# Each level has its own nested MODEL_CONFIG, tool iterations, and max output tokens.
|
||||
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS.
|
||||
# Defaults:
|
||||
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
|
||||
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
|
||||
DIALECTIC_LEVELS__low__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__low__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any
|
||||
# DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5
|
||||
DIALECTIC_LEVELS__medium__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__medium__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__low__TOOL_CHOICE=any
|
||||
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=2
|
||||
DIALECTIC_LEVELS__high__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__high__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__high__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__high__MAX_TOOL_ITERATIONS=4
|
||||
DIALECTIC_LEVELS__max__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__max__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10
|
||||
# Optional overrides:
|
||||
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=your-model-here
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
# Optional backup per level (must set both or neither):
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__MODEL=gemini-2.5-pro
|
||||
# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__TRANSPORT=gemini
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
# SUMMARY_ENABLED=true
|
||||
SUMMARY_PROVIDER=custom
|
||||
SUMMARY_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
# SUMMARY_THINKING_BUDGET_TOKENS=512 # gt=0 required; omit for non-thinking models
|
||||
# Defaults:
|
||||
# SUMMARY_MODEL_CONFIG__TRANSPORT=openai
|
||||
# SUMMARY_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# Optional overrides:
|
||||
# SUMMARY_MODEL_CONFIG__MODEL=your-model-here
|
||||
# SUMMARY_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# SUMMARY_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only
|
||||
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
|
||||
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
|
||||
# SUMMARY_MAX_TOKENS_SHORT=1000
|
||||
# SUMMARY_MAX_TOKENS_LONG=4000
|
||||
# SUMMARY_MODEL_CONFIG__FALLBACK__MODEL=
|
||||
|
||||
# =============================================================================
|
||||
# Dream
|
||||
# =============================================================================
|
||||
# DREAM_ENABLED=true
|
||||
DREAM_PROVIDER=custom
|
||||
DREAM_MODEL=your-model-here # e.g. google/gemini-2.5-flash
|
||||
DREAM_DEDUCTION_MODEL=your-model-here
|
||||
DREAM_INDUCTION_MODEL=your-model-here
|
||||
# DREAM_THINKING_BUDGET_TOKENS=8192 # gt=0 required; omit for non-thinking models
|
||||
# Defaults:
|
||||
# DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
# DREAM_INDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
# Optional overrides:
|
||||
# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=your-model-here
|
||||
# DREAM_DEDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DREAM_INDUCTION_MODEL_CONFIG__MODEL=your-model-here
|
||||
# DREAM_INDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DREAM_DOCUMENT_THRESHOLD=50
|
||||
# DREAM_IDLE_TIMEOUT_MINUTES=60
|
||||
# DREAM_MIN_HOURS_BETWEEN_DREAMS=8
|
||||
# DREAM_ENABLED_TYPES=["omni"]
|
||||
# DREAM_MAX_OUTPUT_TOKENS=16384
|
||||
# DREAM_MAX_TOOL_ITERATIONS=20
|
||||
# DREAM_HISTORY_TOKEN_LIMIT=16384
|
||||
#
|
||||
|
||||
# Surprisal sampling (advanced):
|
||||
# DREAM_SURPRISAL__ENABLED=false
|
||||
# DREAM_SURPRISAL__TREE_TYPE=kdtree
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- New `src/llm/` package as the single owner of provider runtime: clients, backends, history adapters, tool loop, request builder, credentials, and caching policy
|
||||
- `AttemptPlan` dataclass captures per-retry provider selection (client, model, reasoning_effort, thinking_budget_tokens, selected_config) and pins it across stream-final retries so streaming doesn't bounce back to primary after the tool loop has settled on fallback
|
||||
- Gemini JSON-schema sanitizer for `function_declarations` — strips keywords Gemini's validator rejects (`additionalProperties`, `allOf`, etc.) while preserving semantics for all other backends
|
||||
- Dreamer specialists derive `effective_max_tokens` from `model_config.max_output_tokens` with a per-specialist default fallback
|
||||
- Regression tests covering fallback-config thinking-param reach, provider_params → extra_params boundary, OpenAI reasoning-model parameter routing, Gemini blocked finish_reason handling, and fail-fast `max_tool_iterations` validation
|
||||
|
||||
### Changed
|
||||
|
||||
- All LLM orchestration moved out of `src/utils/clients.py` into `src/llm/` with modules split by responsibility (api, executor, tool_loop, runtime, registry, conversation, request_builder, credentials, caching, backends, history_adapters)
|
||||
- Default `ModelConfig` factories (deriver, summary, dreamer specialists, dialectic levels) normalized to `openai/gpt-5.4-mini` with no extra parameters set by default; operators add transport/thinking overrides explicitly
|
||||
- OpenAI reasoning-model routing widened via `_uses_max_completion_tokens` heuristic covering `gpt-5.x` and `o1/o3/o4` — these models receive `max_completion_tokens` instead of `max_tokens`
|
||||
- Override client factories switched from unbounded `@cache` to `@lru_cache(maxsize=128)` for predictable memory growth on long-running processes
|
||||
- `get_backend` now delegates to `client_for_model_config`, so the live-test path and production path share one missing-API-key validation
|
||||
- Blocked Gemini responses (`SAFETY`, `RECITATION`, `PROHIBITED_CONTENT`, `BLOCKLIST`) raise `LLMError` in the streaming path too (previously only the non-streaming path), ensuring retry/fallback logic fires uniformly
|
||||
- Transport-change env overrides now strip transport-specific thinking params (thinking_budget_tokens vs. reasoning_effort) during config merge, including at the dialectic-level merge, so switching from Anthropic → OpenAI doesn't leave orphaned Anthropic-only params that the OpenAI backend would reject
|
||||
- `max_tool_iterations` out-of-range inputs now raise `ValidationException` instead of being silently clamped
|
||||
- Troubleshooting docs updated to reflect nested-env-var form for per-component thinking-budget overrides
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fallback `ModelConfig` temperature and `thinking_budget_tokens` reach the backend on the final retry — previously the primary's values were pre-populated into caller kwargs early and clobbered fallback values via `effective_config_for_call(update=...)`
|
||||
- Stream-final retries pin to the `AttemptPlan` that succeeded rather than re-running provider selection through the outer `current_attempt` ContextVar (which could roll streaming back to primary after the tool loop had already switched to fallback)
|
||||
- OpenAI structured-output calls continue to use `chat.completions.parse()` with strict schema enforcement, while tool-calling paths use `chat.completions.create()` without `strict:True` for broader proxy compatibility (OpenRouter, vLLM, Ollama)
|
||||
- Gemini `cached_content` reuse keys now include `system_instruction` and `tool_config` so cache hits don't cross configurations that differ only in those fields
|
||||
|
||||
### Removed
|
||||
|
||||
- `src/utils/clients.py` deleted; its responsibilities are split across `src/llm/registry.py`, `src/llm/credentials.py`, and the backend-specific modules
|
||||
|
||||
## [3.0.6] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -225,7 +225,6 @@ DB_CONNECTION_URI= # Connection uri for a postgres database (with postgresql+psy
|
|||
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for deriver, summary, and dialectic minimal/low by default)
|
||||
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic medium/high/max and dream by default)
|
||||
LLM_OPENAI_API_KEY= # API Key for OpenAI (used for embeddings when EMBED_MESSAGES=true)
|
||||
LLM_GROQ_API_KEY= # API Key for Groq (optional)
|
||||
```
|
||||
|
||||
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
|
||||
|
|
@ -420,16 +419,17 @@ Then modify the values as needed. The TOML file is organized into sections:
|
|||
|
||||
All configuration values can be overridden using environment variables. The environment variable names follow this pattern:
|
||||
|
||||
- `{SECTION}_{KEY}` for nested settings
|
||||
- `{SECTION}_{KEY}` for top-level section settings
|
||||
- Use `__` inside `{KEY}` for nested settings
|
||||
- Just `{KEY}` for app-level settings
|
||||
|
||||
Examples:
|
||||
|
||||
- `DB_CONNECTION_URI` - Database connection string
|
||||
- `AUTH_JWT_SECRET` - JWT secret key
|
||||
- `DIALECTIC_LEVELS__low__MODEL` - Model for low reasoning level
|
||||
- `DERIVER_PROVIDER` - Provider for background deriver
|
||||
- `SUMMARY_PROVIDER` - Summary generation provider
|
||||
- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver
|
||||
- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override
|
||||
- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level
|
||||
- `LOG_LEVEL` - Application log level
|
||||
- `METRICS_ENABLED` - Enable Prometheus metrics
|
||||
- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ GET_CONTEXT_MAX_TOKENS = 100000
|
|||
MAX_FILE_SIZE = 5242880 # 5MB
|
||||
MAX_MESSAGE_SIZE = 25000 # Characters
|
||||
EMBED_MESSAGES = true
|
||||
MAX_EMBEDDING_TOKENS = 8192
|
||||
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
|
||||
# LANGFUSE_HOST = "https://api.langfuse.com"
|
||||
# LANGFUSE_PUBLIC_KEY = "your-public-key-here"
|
||||
# COLLECT_METRICS_LOCAL = false
|
||||
|
|
@ -51,25 +49,32 @@ PROFILES_SAMPLE_RATE = 0.1
|
|||
# LLM settings
|
||||
[llm]
|
||||
DEFAULT_MAX_TOKENS = 2500
|
||||
EMBEDDING_PROVIDER = "openai"
|
||||
MAX_TOOL_OUTPUT_CHARS = 10000 # Max chars for tool output (~2500 tokens)
|
||||
MAX_MESSAGE_CONTENT_CHARS = 2000 # Max chars per message in tool results
|
||||
|
||||
# API Keys for LLM providers (set the ones you need)
|
||||
# GEMINI_API_KEY = "your-api-key" # Default: deriver, summary, dialectic minimal/low
|
||||
# ANTHROPIC_API_KEY = "your-api-key" # Default: dialectic medium/high/max, dream
|
||||
# OPENAI_API_KEY = "your-api-key" # Default: embeddings
|
||||
# GROQ_API_KEY = "your-api-key" # Not used by default
|
||||
# Supported transports: openai, anthropic, gemini
|
||||
# Base URLs are set per-module via model_config.overrides.base_url
|
||||
# Built-in text-generation defaults use openai / gpt-5.4-mini.
|
||||
# Embeddings default to openai / text-embedding-3-small.
|
||||
OPENAI_API_KEY = "your-api-key-here"
|
||||
# ANTHROPIC_API_KEY = "your-api-key"
|
||||
# GEMINI_API_KEY = "your-api-key"
|
||||
|
||||
# OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, etc.)
|
||||
# Set provider to "custom" in feature config to route calls through this endpoint.
|
||||
# OPENAI_COMPATIBLE_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
|
||||
# Embedding settings
|
||||
[embedding]
|
||||
VECTOR_DIMENSIONS = 1536
|
||||
MAX_INPUT_TOKENS = 8192
|
||||
MAX_TOKENS_PER_REQUEST = 300000
|
||||
|
||||
# vLLM endpoint (for self-hosted models)
|
||||
# Set provider to "vllm" in feature config to route calls through this endpoint.
|
||||
# VLLM_BASE_URL = "http://localhost:8000/v1"
|
||||
# VLLM_API_KEY = "not-needed"
|
||||
[embedding.model_config]
|
||||
transport = "openai"
|
||||
model = "text-embedding-3-small"
|
||||
|
||||
# Optional module-level endpoint overrides
|
||||
# [embedding.model_config.overrides]
|
||||
# base_url = "https://embedding-proxy.internal.example/v1"
|
||||
# api_key_env = "EMBEDDING_CUSTOM_API_KEY"
|
||||
|
||||
# Deriver settings
|
||||
[deriver]
|
||||
|
|
@ -78,20 +83,38 @@ WORKERS = 1
|
|||
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
|
||||
STALE_SESSION_TIMEOUT_MINUTES = 5
|
||||
# QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
# TEMPERATURE = 0.0
|
||||
# BACKUP_PROVIDER = "anthropic"
|
||||
# BACKUP_MODEL = "claude-haiku-4-5"
|
||||
DEDUPLICATE = true
|
||||
MAX_OUTPUT_TOKENS = 4096
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
LOG_OBSERVATIONS = false
|
||||
MAX_INPUT_TOKENS = 23000
|
||||
WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100
|
||||
REPRESENTATION_BATCH_MAX_TOKENS = 1024
|
||||
FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately
|
||||
|
||||
[deriver.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
# temperature = 0.0
|
||||
# thinking_effort = "minimal"
|
||||
# thinking_budget_tokens = 1024
|
||||
# max_output_tokens = 4096
|
||||
|
||||
# Optional module-level endpoint overrides
|
||||
# transport = "openai"
|
||||
# model = "my-local-model"
|
||||
# [deriver.model_config.overrides]
|
||||
# base_url = "https://llm.internal.example/v1"
|
||||
# api_key_env = "DERIVER_CUSTOM_API_KEY"
|
||||
|
||||
# Optional fallback model
|
||||
# [deriver.model_config.fallback]
|
||||
# transport = "anthropic"
|
||||
# model = "claude-haiku-4-5"
|
||||
# [deriver.model_config.fallback.overrides]
|
||||
# base_url = "https://llm-backup.internal.example/v1"
|
||||
# api_key_env = "DERIVER_CUSTOM_BACKUP_API_KEY"
|
||||
# [deriver.model_config.overrides.provider_params]
|
||||
# verbosity = "low"
|
||||
|
||||
# Peer card settings
|
||||
[peer_card]
|
||||
ENABLED = true
|
||||
|
|
@ -106,55 +129,64 @@ SESSION_HISTORY_MAX_TOKENS = 4096
|
|||
# Per-level settings for reasoning levels
|
||||
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global MAX_OUTPUT_TOKENS
|
||||
[dialectic.levels.minimal]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 1
|
||||
MAX_OUTPUT_TOKENS = 250
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.minimal.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.low]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 5
|
||||
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.low.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.medium]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 2
|
||||
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
|
||||
|
||||
[dialectic.levels.medium.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.high]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 4
|
||||
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
|
||||
|
||||
[dialectic.levels.high.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.max]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 2048
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
|
||||
# Backup provider example (optional, must set both or neither):
|
||||
# BACKUP_PROVIDER = "google"
|
||||
# BACKUP_MODEL = "gemini-2.5-pro"
|
||||
|
||||
[dialectic.levels.max.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
# [dialectic.levels.max.model_config.fallback]
|
||||
# transport = "gemini"
|
||||
# model = "gemini-2.5-pro"
|
||||
|
||||
# Summary settings
|
||||
[summary]
|
||||
ENABLED = true
|
||||
MESSAGES_PER_SHORT_SUMMARY = 20
|
||||
MESSAGES_PER_LONG_SUMMARY = 60
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash"
|
||||
MAX_TOKENS_SHORT = 1000
|
||||
MAX_TOKENS_LONG = 4000
|
||||
THINKING_BUDGET_TOKENS = 512
|
||||
# BACKUP_PROVIDER = "google"
|
||||
# BACKUP_MODEL = "gemini-2.5-flash"
|
||||
|
||||
[summary.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
# thinking_effort = "minimal"
|
||||
# thinking_budget_tokens = 1024
|
||||
|
||||
# [summary.model_config.fallback]
|
||||
# transport = "anthropic"
|
||||
# model = "claude-haiku-4-5"
|
||||
|
||||
# Dream settings
|
||||
[dream]
|
||||
|
|
@ -163,18 +195,16 @@ DOCUMENT_THRESHOLD = 50
|
|||
IDLE_TIMEOUT_MINUTES = 60
|
||||
MIN_HOURS_BETWEEN_DREAMS = 8
|
||||
ENABLED_TYPES = ["omni"]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-sonnet-4-20250514"
|
||||
MAX_OUTPUT_TOKENS = 16384
|
||||
THINKING_BUDGET_TOKENS = 8192
|
||||
MAX_TOOL_ITERATIONS = 20
|
||||
HISTORY_TOKEN_LIMIT = 16384
|
||||
# BACKUP_PROVIDER = "google"
|
||||
# BACKUP_MODEL = "gemini-2.5-flash"
|
||||
|
||||
# Specialist models (use same provider as main model)
|
||||
DEDUCTION_MODEL = "claude-haiku-4-5"
|
||||
INDUCTION_MODEL = "claude-haiku-4-5"
|
||||
[dream.deduction_model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dream.induction_model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
# Surprisal-based sampling subsystem
|
||||
[dream.surprisal]
|
||||
|
|
@ -224,6 +254,8 @@ TYPE = "pgvector"
|
|||
# Migration flag: set to true when migration from pgvector is complete
|
||||
MIGRATED = false
|
||||
NAMESPACE = "honcho"
|
||||
# This should match embedding.vector_dimensions. pgvector and dual-write mode
|
||||
# currently still require 1536 until a schema migration lands.
|
||||
DIMENSIONS = 1536
|
||||
# TURBOPUFFER_API_KEY = "your-turbopuffer-api-key"
|
||||
# TURBOPUFFER_REGION = "us-east-1"
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ cp config.toml.example config.toml
|
|||
|
||||
All config values map to environment variables:
|
||||
|
||||
- `{SECTION}_{KEY}` for section settings (e.g., `DB_CONNECTION_URI` → `[db].CONNECTION_URI`)
|
||||
- `{SECTION}_{KEY}` for top-level section settings (e.g., `DB_CONNECTION_URI` → `[db].CONNECTION_URI`)
|
||||
- `{KEY}` for app-level settings (e.g., `LOG_LEVEL` → `[app].LOG_LEVEL`)
|
||||
- `{SECTION}__{NESTED}__{KEY}` for deeply nested settings (double underscore, e.g., `DIALECTIC_LEVELS__minimal__PROVIDER`)
|
||||
- Use `__` inside `{KEY}` for nested settings (e.g., `DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT`, `DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL`)
|
||||
|
||||
## LLM Configuration
|
||||
|
||||
The [Self-Hosting Guide](./self-hosting#llm-setup) covers the basic setup: one OpenAI-compatible endpoint, one model for all features. This section covers recommended model tiers, using multiple providers, and per-feature tuning.
|
||||
The [Self-Hosting Guide](./self-hosting#llm-setup) covers the basic setup: either the built-in OpenAI defaults or one OpenAI-compatible endpoint/model for all features. This section covers recommended model tiers, using multiple providers, and per-feature tuning.
|
||||
|
||||
<Note>
|
||||
All Honcho agents (deriver, dialectic, dream) require tool calling. Your models must support the OpenAI tool calling format.
|
||||
|
|
@ -52,104 +52,175 @@ You can mix providers freely — for example, use Gemini for the deriver and Cla
|
|||
|
||||
### Provider Types
|
||||
|
||||
| Provider value | What it connects to | Key env var |
|
||||
| Transport value | What it connects to | API key env var |
|
||||
|---|---|---|
|
||||
| `custom` | Any OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, Ollama) | `LLM_OPENAI_COMPATIBLE_API_KEY` + `LLM_OPENAI_COMPATIBLE_BASE_URL` |
|
||||
| `vllm` | vLLM self-hosted models | `LLM_VLLM_API_KEY` + `LLM_VLLM_BASE_URL` |
|
||||
| `google` | Google Gemini (direct) | `LLM_GEMINI_API_KEY` |
|
||||
| `openai` | OpenAI or any OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, vLLM, Ollama) | `LLM_OPENAI_API_KEY` |
|
||||
| `anthropic` | Anthropic Claude (direct) | `LLM_ANTHROPIC_API_KEY` |
|
||||
| `openai` | OpenAI (direct) | `LLM_OPENAI_API_KEY` |
|
||||
| `groq` | Groq (direct) | `LLM_GROQ_API_KEY` |
|
||||
| `gemini` | Google Gemini (direct) | `LLM_GEMINI_API_KEY` |
|
||||
|
||||
For OpenAI-compatible proxies (OpenRouter, vLLM, Ollama, etc.), use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature to point at your endpoint.
|
||||
|
||||
### Tiered Model Setup
|
||||
|
||||
Once you're past initial setup, you can assign different models per feature for better cost/quality tradeoffs. This example uses OpenRouter with light/medium/heavy tiers:
|
||||
|
||||
```bash
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
|
||||
LLM_OPENAI_API_KEY=sk-or-v1-...
|
||||
|
||||
# All features route through OpenRouter via overrides.base_url
|
||||
# (You can set this on each feature's MODEL_CONFIG)
|
||||
|
||||
# Light tier — high throughput, cheap
|
||||
DERIVER_PROVIDER=custom
|
||||
DERIVER_MODEL=google/gemini-2.5-flash-lite
|
||||
SUMMARY_PROVIDER=custom
|
||||
SUMMARY_MODEL=google/gemini-2.5-flash
|
||||
DIALECTIC_LEVELS__minimal__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__minimal__MODEL=google/gemini-2.5-flash-lite
|
||||
DIALECTIC_LEVELS__low__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__low__MODEL=google/gemini-2.5-flash-lite
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
DERIVER_MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
SUMMARY_MODEL_CONFIG__TRANSPORT=openai
|
||||
SUMMARY_MODEL_CONFIG__MODEL=google/gemini-2.5-flash
|
||||
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite
|
||||
DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=google/gemini-2.5-flash-lite
|
||||
|
||||
# Medium tier — better reasoning
|
||||
DIALECTIC_LEVELS__medium__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__medium__MODEL=anthropic/claude-haiku-4-5
|
||||
DIALECTIC_LEVELS__high__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__high__MODEL=anthropic/claude-haiku-4-5
|
||||
DIALECTIC_LEVELS__max__PROVIDER=custom
|
||||
DIALECTIC_LEVELS__max__MODEL=anthropic/claude-haiku-4-5
|
||||
DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5
|
||||
DIALECTIC_LEVELS__high__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5
|
||||
DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5
|
||||
|
||||
# Heavy tier — best quality for complex tasks
|
||||
DREAM_PROVIDER=custom
|
||||
DREAM_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
DREAM_DEDUCTION_MODEL=anthropic/claude-haiku-4-5
|
||||
DREAM_INDUCTION_MODEL=anthropic/claude-haiku-4-5
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5
|
||||
DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
DREAM_INDUCTION_MODEL_CONFIG__MODEL=anthropic/claude-haiku-4-5
|
||||
```
|
||||
|
||||
### Direct Vendor Keys
|
||||
|
||||
Instead of an OpenAI-compatible proxy, you can use vendor APIs directly. Leave `PROVIDER` overrides unset and the code defaults route per feature:
|
||||
Instead of an OpenAI-compatible proxy, you can use vendor APIs directly. Each transport picks up its own `LLM_{TRANSPORT}_API_KEY`.
|
||||
|
||||
If you keep the built-in defaults, only `LLM_OPENAI_API_KEY` is required:
|
||||
|
||||
```bash
|
||||
LLM_GEMINI_API_KEY=... # deriver, summary, dialectic minimal/low
|
||||
LLM_ANTHROPIC_API_KEY=... # dialectic medium/high/max, dream
|
||||
LLM_OPENAI_API_KEY=... # embeddings
|
||||
LLM_OPENAI_API_KEY=...
|
||||
|
||||
# Built-in model defaults
|
||||
# - deriver: openai / gpt-5.4-mini
|
||||
# - dialectic (all levels): openai / gpt-5.4-mini
|
||||
# - summary: openai / gpt-5.4-mini
|
||||
# - dream specialists: openai / gpt-5.4-mini
|
||||
# - embeddings: openai / text-embedding-3-small
|
||||
```
|
||||
|
||||
To use Gemini or Anthropic directly, override the features you want to move:
|
||||
|
||||
```bash
|
||||
LLM_GEMINI_API_KEY=...
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=gemini
|
||||
DERIVER_MODEL_CONFIG__MODEL=gemini-2.5-flash
|
||||
|
||||
LLM_ANTHROPIC_API_KEY=...
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=anthropic
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__MODEL=claude-haiku-4-5
|
||||
```
|
||||
|
||||
### Self-Hosted (vLLM / Ollama)
|
||||
|
||||
Use `transport = "openai"` and set `MODEL_CONFIG__OVERRIDES__BASE_URL` on each feature:
|
||||
|
||||
```bash
|
||||
# vLLM
|
||||
LLM_VLLM_BASE_URL=http://localhost:8000/v1
|
||||
LLM_VLLM_API_KEY=not-needed
|
||||
DERIVER_PROVIDER=vllm
|
||||
DERIVER_MODEL=your-model-name
|
||||
LLM_OPENAI_API_KEY=not-needed
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
DERIVER_MODEL_CONFIG__MODEL=your-model-name
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1
|
||||
|
||||
# Ollama (uses custom provider)
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=http://localhost:11434/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=ollama
|
||||
DERIVER_PROVIDER=custom
|
||||
DERIVER_MODEL=llama3.3:70b
|
||||
# Ollama
|
||||
LLM_OPENAI_API_KEY=ollama
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
DERIVER_MODEL_CONFIG__MODEL=llama3.3:70b
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:11434/v1
|
||||
```
|
||||
|
||||
Set `PROVIDER` and `MODEL` for each feature the same way.
|
||||
Set `MODEL_CONFIG__TRANSPORT`, `MODEL_CONFIG__MODEL`, and `MODEL_CONFIG__OVERRIDES__BASE_URL` for each feature the same way.
|
||||
|
||||
The same overrides are available in `config.toml`:
|
||||
|
||||
```toml
|
||||
[deriver.model_config]
|
||||
transport = "openai"
|
||||
model = "my-local-model"
|
||||
|
||||
[deriver.model_config.overrides]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
api_key_env = "DERIVER_LOCAL_API_KEY"
|
||||
```
|
||||
|
||||
### Thinking Budget
|
||||
|
||||
Default configs use `THINKING_BUDGET_TOKENS` tuned for Anthropic models. Non-Anthropic providers don't support extended thinking and will error or silently fail. The [Self-Hosting Guide](./self-hosting#llm-setup) sets these to `0` by default. If you switch to Anthropic models, you can re-enable them:
|
||||
Built-in defaults do not set `MODEL_CONFIG__THINKING_BUDGET_TOKENS` or `MODEL_CONFIG__THINKING_EFFORT`. Add one only when your chosen model supports it.
|
||||
|
||||
Use `MODEL_CONFIG__THINKING_EFFORT` for OpenAI reasoning models:
|
||||
|
||||
```bash
|
||||
# Anthropic models — enable thinking
|
||||
DERIVER_THINKING_BUDGET_TOKENS=1024
|
||||
SUMMARY_THINKING_BUDGET_TOKENS=512
|
||||
DREAM_THINKING_BUDGET_TOKENS=8192
|
||||
DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=1024
|
||||
DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=1024
|
||||
DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=2048
|
||||
# minimal and low stay at 0
|
||||
DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium
|
||||
```
|
||||
|
||||
Use `MODEL_CONFIG__THINKING_BUDGET_TOKENS` for Anthropic and Gemini models. Set it to `0` or omit it for providers that don't support extended thinking:
|
||||
|
||||
```bash
|
||||
SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
```
|
||||
|
||||
### Provider-Specific Parameters
|
||||
|
||||
Each model config supports an `overrides.provider_params` dict for passing arbitrary parameters to the underlying provider SDK. Use this for vendor-specific features that aren't part of the standard config:
|
||||
|
||||
```toml
|
||||
[deriver.model_config.overrides.provider_params]
|
||||
# These are passed directly to the provider SDK
|
||||
verbosity = "low"
|
||||
```
|
||||
|
||||
### Changing Transport
|
||||
|
||||
When changing a feature's `transport`, always specify `model` explicitly. Partial overrides that change transport without model will keep the previous model name, which may not be valid for the new provider.
|
||||
|
||||
### General LLM Settings
|
||||
|
||||
```bash
|
||||
LLM_DEFAULT_MAX_TOKENS=2500
|
||||
|
||||
# Embedding provider (used when EMBED_MESSAGES=true)
|
||||
LLM_EMBEDDING_PROVIDER=openai # Options: openai, gemini, openrouter
|
||||
|
||||
# Tool output limits (to prevent token explosion)
|
||||
LLM_MAX_TOOL_OUTPUT_CHARS=10000 # ~2500 tokens at 4 chars/token
|
||||
LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results
|
||||
```
|
||||
|
||||
### Embedding Configuration
|
||||
|
||||
Embeddings use their own nested model config, separate from the main text-generation LLM settings.
|
||||
|
||||
```bash
|
||||
# Embedding vector settings
|
||||
EMBEDDING_VECTOR_DIMENSIONS=1536
|
||||
EMBEDDING_MAX_INPUT_TOKENS=8192
|
||||
EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
|
||||
|
||||
# Embedding transport/model selection
|
||||
EMBEDDING_MODEL_CONFIG__TRANSPORT=openai # openai, gemini
|
||||
EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small
|
||||
|
||||
# Optional endpoint overrides
|
||||
EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8000/v1
|
||||
EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=EMBEDDING_CUSTOM_API_KEY
|
||||
```
|
||||
|
||||
Current constraint:
|
||||
- `EMBEDDING_VECTOR_DIMENSIONS` can be changed for fully migrated external vector stores, but pgvector and dual-write mode still require `1536` until the schema migration lands.
|
||||
|
||||
### Feature-Specific Model Configuration
|
||||
|
||||
Each feature can use a different provider and model. Below are all the tuning knobs.
|
||||
|
|
@ -173,45 +244,51 @@ Each reasoning level has its own provider, model, and settings:
|
|||
```toml
|
||||
# config.toml example
|
||||
[dialectic.levels.minimal]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 1
|
||||
MAX_OUTPUT_TOKENS = 250
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.minimal.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.low]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 5
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.low.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.medium]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 2
|
||||
|
||||
[dialectic.levels.medium.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.high]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 4
|
||||
|
||||
[dialectic.levels.high.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.max]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 2048
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
[dialectic.levels.max.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
```
|
||||
|
||||
Environment variables for nested levels use double underscores:
|
||||
```bash
|
||||
DIALECTIC_LEVELS__minimal__PROVIDER=google
|
||||
DIALECTIC_LEVELS__minimal__MODEL=gemini-2.5-flash-lite
|
||||
DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai
|
||||
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
|
||||
DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
|
||||
DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any
|
||||
```
|
||||
|
||||
**Deriver (Theory of Mind):**
|
||||
|
|
@ -222,12 +299,16 @@ The Deriver extracts facts from messages and builds theory-of-mind representatio
|
|||
DERIVER_ENABLED=true
|
||||
|
||||
# LLM settings
|
||||
DERIVER_PROVIDER=google
|
||||
DERIVER_MODEL=gemini-2.5-flash-lite
|
||||
DERIVER_MAX_OUTPUT_TOKENS=4096
|
||||
DERIVER_THINKING_BUDGET_TOKENS=1024
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
DERIVER_MAX_INPUT_TOKENS=23000
|
||||
DERIVER_TEMPERATURE= # Optional override (unset by default)
|
||||
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
# DERIVER_MODEL_CONFIG__TEMPERATURE=0.7 # Optional temperature override
|
||||
|
||||
# Backup model (optional)
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=claude-haiku-4-5
|
||||
# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT=anthropic
|
||||
|
||||
# Worker settings
|
||||
DERIVER_WORKERS=1 # Increase for higher throughput
|
||||
|
|
@ -256,11 +337,12 @@ Session summaries provide compressed context for long conversations — short su
|
|||
|
||||
```bash
|
||||
SUMMARY_ENABLED=true
|
||||
SUMMARY_PROVIDER=google
|
||||
SUMMARY_MODEL=gemini-2.5-flash
|
||||
SUMMARY_MODEL_CONFIG__TRANSPORT=openai
|
||||
SUMMARY_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
SUMMARY_MAX_TOKENS_SHORT=1000
|
||||
SUMMARY_MAX_TOKENS_LONG=4000
|
||||
SUMMARY_THINKING_BUDGET_TOKENS=512
|
||||
# SUMMARY_MODEL_CONFIG__THINKING_EFFORT=minimal
|
||||
# SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
|
||||
SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
|
||||
SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
|
||||
```
|
||||
|
|
@ -275,18 +357,14 @@ DREAM_DOCUMENT_THRESHOLD=50
|
|||
DREAM_IDLE_TIMEOUT_MINUTES=60
|
||||
DREAM_MIN_HOURS_BETWEEN_DREAMS=8
|
||||
DREAM_ENABLED_TYPES=["omni"]
|
||||
|
||||
# LLM settings
|
||||
DREAM_PROVIDER=anthropic
|
||||
DREAM_MODEL=claude-sonnet-4-20250514
|
||||
DREAM_MAX_OUTPUT_TOKENS=16384
|
||||
DREAM_THINKING_BUDGET_TOKENS=8192
|
||||
DREAM_MAX_TOOL_ITERATIONS=20
|
||||
DREAM_HISTORY_TOKEN_LIMIT=16384
|
||||
|
||||
# Specialist models (use same provider as main model)
|
||||
DREAM_DEDUCTION_MODEL=claude-haiku-4-5
|
||||
DREAM_INDUCTION_MODEL=claude-haiku-4-5
|
||||
# Specialist model configs (each is independent)
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai
|
||||
DREAM_INDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
**Surprisal-Based Sampling (Advanced):**
|
||||
|
|
@ -315,8 +393,8 @@ GET_CONTEXT_MAX_TOKENS=100000
|
|||
MAX_MESSAGE_SIZE=25000
|
||||
MAX_FILE_SIZE=5242880 # 5MB
|
||||
EMBED_MESSAGES=true
|
||||
MAX_EMBEDDING_TOKENS=8192
|
||||
MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
|
||||
EMBEDDING_MAX_INPUT_TOKENS=8192
|
||||
EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
|
||||
NAMESPACE=honcho
|
||||
```
|
||||
|
||||
|
|
@ -452,8 +530,10 @@ DEFAULT_TTL_SECONDS = 300
|
|||
[deriver]
|
||||
ENABLED = true
|
||||
WORKERS = 1
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
|
||||
[deriver.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[peer_card]
|
||||
ENABLED = true
|
||||
|
|
@ -462,44 +542,62 @@ ENABLED = true
|
|||
MAX_OUTPUT_TOKENS = 8192
|
||||
|
||||
[dialectic.levels.minimal]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 1
|
||||
MAX_OUTPUT_TOKENS = 250
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.minimal.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.low]
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash-lite"
|
||||
THINKING_BUDGET_TOKENS = 0
|
||||
MAX_TOOL_ITERATIONS = 5
|
||||
TOOL_CHOICE = "any"
|
||||
|
||||
[dialectic.levels.low.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.medium]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 2
|
||||
|
||||
[dialectic.levels.medium.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.high]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
MAX_TOOL_ITERATIONS = 4
|
||||
|
||||
[dialectic.levels.high.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dialectic.levels.max]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-haiku-4-5"
|
||||
THINKING_BUDGET_TOKENS = 2048
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
[dialectic.levels.max.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[summary]
|
||||
ENABLED = true
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.5-flash"
|
||||
MAX_TOKENS_SHORT = 1000
|
||||
MAX_TOKENS_LONG = 4000
|
||||
|
||||
[summary.model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dream]
|
||||
ENABLED = true
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-sonnet-4-20250514"
|
||||
|
||||
[dream.deduction_model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[dream.induction_model_config]
|
||||
transport = "openai"
|
||||
model = "gpt-5.4-mini"
|
||||
|
||||
[webhook]
|
||||
MAX_WORKSPACE_LIMIT = 10
|
||||
|
|
@ -536,6 +634,6 @@ uv run alembic revision --autogenerate -m "Description" # Create new migration
|
|||
|
||||
4. **Deriver not processing** — Check logs. Increase `DERIVER_WORKERS` for throughput. Verify database and LLM connectivity.
|
||||
|
||||
5. **Dialectic level issues** — All five levels must be configured. For Anthropic, `THINKING_BUDGET_TOKENS` must be >= 1024. For non-Anthropic providers, set to `0`. `MAX_OUTPUT_TOKENS` must exceed `THINKING_BUDGET_TOKENS`.
|
||||
5. **Dialectic level issues** — Unset level fields inherit from the built-in defaults. For Anthropic, `THINKING_BUDGET_TOKENS` must be >= 1024 when enabled. For providers without budgeted thinking, omit it or set it to `0`. `MAX_OUTPUT_TOKENS` must exceed `THINKING_BUDGET_TOKENS`.
|
||||
|
||||
6. **Vector store issues** — For Turbopuffer, set the API key. Check `VECTOR_STORE_DIMENSIONS` matches your embedding model.
|
||||
|
|
|
|||
|
|
@ -36,30 +36,24 @@ You'll need a PostgreSQL database with the pgvector extension. Choose one:
|
|||
|
||||
Honcho uses LLMs for memory extraction, summarization, dialectic chat, and dreaming. The server will **fail to start** without a provider configured.
|
||||
|
||||
You need one API key and one model. Any OpenAI-compatible endpoint works — OpenRouter, Together, Fireworks, Ollama, vLLM, or a direct vendor API. Models must support tool calling (function calling).
|
||||
If you keep the built-in defaults, you only need one API key: all text-generation features default to `openai / gpt-5.4-mini`, and embeddings default to `openai / text-embedding-3-small`. Any OpenAI-compatible endpoint works too — OpenRouter, Together, Fireworks, Ollama, vLLM, or LiteLLM. Models must support tool calling (function calling).
|
||||
|
||||
The `.env.template` has provider and model lines ready for each feature. After copying it to `.env`, you need to set three things:
|
||||
After copying `.env.template` to `.env`, the default setup is:
|
||||
|
||||
```bash
|
||||
# 1. Your endpoint and API key (already uncommented in the template)
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
|
||||
|
||||
# 2. Replace "your-model-here" everywhere with your model
|
||||
# (these are spread across the Deriver, Dialectic, Summary, and Dream sections)
|
||||
DERIVER_MODEL=google/gemini-2.5-flash # e.g. google/gemini-2.5-flash
|
||||
SUMMARY_MODEL=google/gemini-2.5-flash
|
||||
DREAM_MODEL=google/gemini-2.5-flash
|
||||
DIALECTIC_LEVELS__minimal__MODEL=google/gemini-2.5-flash
|
||||
# ... same for low, medium, high, max
|
||||
|
||||
# 3. Everything else is already configured:
|
||||
# - PROVIDER=custom for all features (routes through your endpoint)
|
||||
# - THINKING_BUDGET_TOKENS=0 (correct for non-Anthropic models)
|
||||
# - LLM_EMBEDDING_PROVIDER=openrouter (uses same endpoint for embeddings)
|
||||
# Required for the built-in defaults
|
||||
LLM_OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Use find-and-replace to swap all `your-model-here` with your chosen model in one step.
|
||||
If you want a different model or an OpenAI-compatible proxy, uncomment and edit the relevant `*_MODEL_CONFIG__TRANSPORT`, `*_MODEL_CONFIG__MODEL`, and `*_MODEL_CONFIG__OVERRIDES__BASE_URL` lines in the Deriver, Dialectic, Summary, and Dream sections. For example:
|
||||
|
||||
```bash
|
||||
LLM_OPENAI_API_KEY=sk-or-v1-...
|
||||
|
||||
DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
DERIVER_MODEL_CONFIG__MODEL=google/gemini-2.5-flash
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
```
|
||||
|
||||
<Info>
|
||||
For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration).
|
||||
|
|
|
|||
|
|
@ -115,12 +115,12 @@ Messages are stored but no observations, summaries, or representations are being
|
|||
|
||||
### OpenRouter / custom provider not working
|
||||
|
||||
If you set `PROVIDER=custom` but calls fail:
|
||||
If calls to an OpenAI-compatible proxy fail:
|
||||
|
||||
1. **Verify the endpoint and key are set:**
|
||||
1. **Verify the endpoint and key are set.** Use `transport = "openai"` with a base URL override:
|
||||
```bash
|
||||
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
|
||||
LLM_OPENAI_API_KEY=sk-or-v1-...
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
```
|
||||
|
||||
2. **Check model names match the provider's format.** OpenRouter uses `vendor/model` format (e.g., `anthropic/claude-haiku-4-5`), not the raw model ID.
|
||||
|
|
@ -139,29 +139,30 @@ If you set `PROVIDER=custom` but calls fail:
|
|||
|
||||
2. **In Docker**, `localhost` inside a container doesn't reach the host. Use `host.docker.internal` (macOS/Windows) or the host's network IP:
|
||||
```bash
|
||||
LLM_VLLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://host.docker.internal:8000/v1
|
||||
```
|
||||
|
||||
3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response.
|
||||
|
||||
### Thinking budget errors with non-Anthropic providers
|
||||
|
||||
If you see errors like `thinking budget not supported`, `invalid parameter`, or silent failures where agents produce no output, your `THINKING_BUDGET_TOKENS` is likely set to a value > 0 with a provider that doesn't support Anthropic-style extended thinking.
|
||||
If you see errors like `thinking budget not supported`, `invalid parameter`, or silent failures where agents produce no output, one of your per-component `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS` overrides is likely set to a value > 0 with a provider that doesn't support Anthropic-style extended thinking. The built-in defaults do not set thinking budgets, so this only applies if you added those overrides yourself.
|
||||
|
||||
**Fix:** Set `THINKING_BUDGET_TOKENS=0` for every component when using non-Anthropic providers:
|
||||
**Fix:** Set `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0` for every component when using models that don't support thinking:
|
||||
|
||||
```bash
|
||||
DERIVER_THINKING_BUDGET_TOKENS=0
|
||||
SUMMARY_THINKING_BUDGET_TOKENS=0
|
||||
DREAM_THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
|
||||
DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DREAM_DEDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DREAM_INDUCTION_MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__minimal__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__low__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__medium__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__high__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=0
|
||||
```
|
||||
|
||||
This applies to OpenRouter (with non-Anthropic models), vLLM, Ollama, Groq, Google, and OpenAI providers. Only Anthropic models support the thinking budget parameter.
|
||||
For OpenAI reasoning models, use `*_MODEL_CONFIG__THINKING_EFFORT` instead of `*_MODEL_CONFIG__THINKING_BUDGET_TOKENS`.
|
||||
|
||||
## Database Issues
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ The current plugin gives agent peers explicit observation settings:
|
|||
- `observe_me` defaults to `true`
|
||||
- `observe_others` defaults to `true`
|
||||
|
||||
In practice, that means agent peers can both be observed by Honcho and form representations of other peers they interact with.
|
||||
In practice, that means agent peers can both be observed by Honcho and form representations of other peers they interact with.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ readme = "README.md"
|
|||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.131.0",
|
||||
"groq>=0.31.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"fastapi-pagination>=0.14.2",
|
||||
|
|
@ -95,6 +94,12 @@ asyncio_default_fixture_loop_scope = "session"
|
|||
addopts = "--strict-markers -n auto --ignore=tests/alembic"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
markers = [
|
||||
"live_llm: calls live LLM provider APIs and requires --live-llm",
|
||||
"requires_anthropic: requires LLM_ANTHROPIC_API_KEY",
|
||||
"requires_openai: requires LLM_OPENAI_API_KEY",
|
||||
"requires_gemini: requires LLM_GEMINI_API_KEY",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore:Call to deprecated close\\. \\(Use aclose\\(\\) instead\\).*:DeprecationWarning",
|
||||
"ignore:websockets\\.legacy is deprecated; see .* for upgrade instructions:DeprecationWarning",
|
||||
|
|
|
|||
867
src/config.py
867
src/config.py
File diff suppressed because it is too large
Load Diff
|
|
@ -348,7 +348,8 @@ async def query_documents(
|
|||
embedding = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
raise ValidationException(
|
||||
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
|
||||
"Query exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
) from e
|
||||
|
||||
if _uses_pgvector():
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ class RepresentationManager:
|
|||
embeddings = await embedding_client.simple_batch_embed(observation_texts)
|
||||
except ValueError as e:
|
||||
raise exceptions.ValidationException(
|
||||
f"Observation content exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
|
||||
"Observation content exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
) from e
|
||||
|
||||
batch_embed_duration = (time.perf_counter() - batch_embed_start) * 1000
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ def setup_logging():
|
|||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("openai._base_client").setLevel(logging.WARNING)
|
||||
logging.getLogger("groq._base_client").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
async def run_deriver():
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import logging
|
|||
import time
|
||||
|
||||
from src import crud
|
||||
from src.config import settings
|
||||
from src.config import ConfiguredModelSettings, settings
|
||||
from src.crud.representation import RepresentationManager
|
||||
from src.dependencies import tracked_db
|
||||
from src.llm import honcho_llm_call
|
||||
from src.models import Message
|
||||
from src.schemas import ResolvedConfiguration
|
||||
from src.telemetry import prometheus_metrics
|
||||
|
|
@ -16,7 +17,6 @@ from src.telemetry.prometheus.metrics import (
|
|||
TokenTypes,
|
||||
)
|
||||
from src.telemetry.sentry import with_sentry_transaction
|
||||
from src.utils.clients import honcho_llm_call
|
||||
from src.utils.config_helpers import get_configuration
|
||||
from src.utils.formatting import format_new_turn_with_timestamp
|
||||
from src.utils.representation import PromptRepresentation, Representation
|
||||
|
|
@ -27,6 +27,10 @@ from .prompts import estimate_minimal_deriver_prompt_tokens, minimal_deriver_pro
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_deriver_model_config() -> ConfiguredModelSettings:
|
||||
return settings.DERIVER.MODEL_CONFIG
|
||||
|
||||
|
||||
@with_sentry_transaction("minimal_deriver_batch", op="deriver")
|
||||
async def process_representation_tasks_batch(
|
||||
messages: list[Message],
|
||||
|
|
@ -119,22 +123,24 @@ async def process_representation_tasks_batch(
|
|||
)
|
||||
|
||||
# validation on settings means max_tokens will always be > 0
|
||||
max_tokens = settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS
|
||||
base_model_config = _get_deriver_model_config()
|
||||
max_tokens = base_model_config.max_output_tokens or settings.LLM.DEFAULT_MAX_TOKENS
|
||||
model_config = base_model_config.model_copy(
|
||||
update={
|
||||
"stop_sequences": [" \n", "\n\n\n\n"],
|
||||
}
|
||||
)
|
||||
|
||||
# Single LLM call
|
||||
llm_start = time.perf_counter()
|
||||
response = await honcho_llm_call(
|
||||
llm_settings=settings.DERIVER,
|
||||
model_config=model_config,
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
track_name="Minimal Deriver",
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
temperature=settings.DERIVER.TEMPERATURE,
|
||||
stop_seqs=[" \n", "\n\n\n\n"],
|
||||
thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS,
|
||||
max_input_tokens=settings.DERIVER.MAX_INPUT_TOKENS,
|
||||
reasoning_effort="minimal",
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
trace_name="minimal_deriver",
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ from collections.abc import AsyncIterator, Callable
|
|||
from typing import Any, cast
|
||||
|
||||
from src import crud
|
||||
from src.config import ReasoningLevel, settings
|
||||
from src.config import ConfiguredModelSettings, ReasoningLevel, settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.dialectic import prompts
|
||||
from src.embedding_client import embedding_client
|
||||
from src.llm import (
|
||||
HonchoLLMCallResponse,
|
||||
StreamingResponseWithMetadata,
|
||||
honcho_llm_call,
|
||||
)
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import DialecticCompletedEvent, emit
|
||||
from src.telemetry.logging import (
|
||||
|
|
@ -30,16 +35,17 @@ from src.utils.agent_tools import (
|
|||
create_tool_executor,
|
||||
search_memory,
|
||||
)
|
||||
from src.utils.clients import (
|
||||
HonchoLLMCallResponse,
|
||||
StreamingResponseWithMetadata,
|
||||
honcho_llm_call,
|
||||
)
|
||||
from src.utils.formatting import format_new_turn_with_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_dialectic_level_model_config(
|
||||
reasoning_level: ReasoningLevel,
|
||||
) -> ConfiguredModelSettings:
|
||||
return settings.DIALECTIC.LEVELS[reasoning_level].MODEL_CONFIG
|
||||
|
||||
|
||||
class DialecticAgent:
|
||||
"""
|
||||
An agentic dialectic that iteratively gathers context to answer queries.
|
||||
|
|
@ -405,7 +411,7 @@ class DialecticAgent:
|
|||
)
|
||||
|
||||
response: HonchoLLMCallResponse[str] = await honcho_llm_call(
|
||||
llm_settings=level_settings,
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
|
|
@ -414,7 +420,6 @@ class DialecticAgent:
|
|||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
track_name="Dialectic Agent",
|
||||
thinking_budget_tokens=level_settings.THINKING_BUDGET_TOKENS,
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
)
|
||||
|
|
@ -471,7 +476,7 @@ class DialecticAgent:
|
|||
response = cast(
|
||||
StreamingResponseWithMetadata,
|
||||
await honcho_llm_call(
|
||||
llm_settings=level_settings,
|
||||
model_config=_get_dialectic_level_model_config(self.reasoning_level),
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
|
|
@ -482,7 +487,6 @@ class DialecticAgent:
|
|||
max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS,
|
||||
messages=self.messages,
|
||||
track_name="Dialectic Agent Stream",
|
||||
thinking_budget_tokens=level_settings.THINKING_BUDGET_TOKENS,
|
||||
max_input_tokens=settings.DIALECTIC.MAX_INPUT_TOKENS,
|
||||
trace_name="dialectic_chat",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from src import crud, schemas
|
||||
from src.config import settings
|
||||
from src.config import ConfiguredModelSettings, settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.exceptions import ValidationException
|
||||
from src.llm import HonchoLLMCallResponse, honcho_llm_call
|
||||
from src.schemas import ResolvedConfiguration
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import DreamSpecialistEvent, emit
|
||||
|
|
@ -31,11 +33,22 @@ from src.utils.agent_tools import (
|
|||
INDUCTION_SPECIALIST_TOOLS,
|
||||
create_tool_executor,
|
||||
)
|
||||
from src.utils.clients import HonchoLLMCallResponse, honcho_llm_call
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _require_specialist_model_config(
|
||||
model_config: ConfiguredModelSettings | None,
|
||||
*,
|
||||
specialist_name: str,
|
||||
) -> ConfiguredModelSettings:
|
||||
if model_config is None:
|
||||
raise ValidationException(
|
||||
f"{specialist_name} MODEL_CONFIG must be resolved before use"
|
||||
)
|
||||
return model_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistResult:
|
||||
"""Result of a specialist run for telemetry and aggregation."""
|
||||
|
|
@ -70,8 +83,8 @@ class BaseSpecialist(ABC):
|
|||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_model(self) -> str:
|
||||
"""Get the model to use for this specialist."""
|
||||
def get_model_config(self) -> ConfiguredModelSettings:
|
||||
"""Get the configured model to use for this specialist."""
|
||||
...
|
||||
|
||||
def get_max_tokens(self) -> int:
|
||||
|
|
@ -196,9 +209,18 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
parent_category="dream",
|
||||
)
|
||||
|
||||
# Get model with potential override
|
||||
model = self.get_model()
|
||||
llm_settings = settings.DREAM.model_copy(update={"MODEL": model})
|
||||
model_config = self.get_model_config()
|
||||
|
||||
# Respect operator-configured max_output_tokens on the specialist's
|
||||
# ModelConfig (e.g. DREAM_DEDUCTION_MODEL_CONFIG__MAX_OUTPUT_TOKENS).
|
||||
# Only fall back to the specialist's hardcoded default when the
|
||||
# config leaves max_output_tokens unset or non-positive.
|
||||
configured_max = model_config.max_output_tokens
|
||||
effective_max_tokens = (
|
||||
configured_max
|
||||
if configured_max and configured_max > 0
|
||||
else self.get_max_tokens()
|
||||
)
|
||||
|
||||
# Track iterations via callback
|
||||
iteration_count = 0
|
||||
|
|
@ -209,9 +231,9 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
|
||||
# Run the agent loop
|
||||
response: HonchoLLMCallResponse[str] = await honcho_llm_call(
|
||||
llm_settings=llm_settings,
|
||||
model_config=model_config,
|
||||
prompt="", # Ignored since we pass messages
|
||||
max_tokens=self.get_max_tokens(),
|
||||
max_tokens=effective_max_tokens,
|
||||
tools=self.get_tools(peer_card_enabled=peer_card_enabled),
|
||||
tool_choice=None,
|
||||
tool_executor=tool_executor,
|
||||
|
|
@ -305,8 +327,11 @@ class DeductionSpecialist(BaseSpecialist):
|
|||
if t["name"] not in PEER_CARD_TOOL_NAMES
|
||||
]
|
||||
|
||||
def get_model(self) -> str:
|
||||
return settings.DREAM.DEDUCTION_MODEL
|
||||
def get_model_config(self) -> ConfiguredModelSettings:
|
||||
return _require_specialist_model_config(
|
||||
settings.DREAM.DEDUCTION_MODEL_CONFIG,
|
||||
specialist_name="DREAM DEDUCTION",
|
||||
)
|
||||
|
||||
def get_max_tokens(self) -> int:
|
||||
return 8192
|
||||
|
|
@ -377,11 +402,12 @@ When statements can't both be true (not just updates), flag them:
|
|||
|
||||
## CREATING OBSERVATIONS
|
||||
|
||||
Use `create_observations_deductive`.
|
||||
|
||||
```json
|
||||
{{
|
||||
"observations": [{{
|
||||
"content": "The logical conclusion",
|
||||
"level": "deductive", // or "contradiction"
|
||||
"source_ids": ["id1", "id2"],
|
||||
"premises": ["premise 1 text", "premise 2 text"]
|
||||
}}]
|
||||
|
|
@ -393,8 +419,9 @@ When statements can't both be true (not just updates), flag them:
|
|||
1. Don't explain your reasoning - just call tools
|
||||
2. Create observations based on what you ACTUALLY FIND, not what you expect
|
||||
3. Always include source_ids linking to the observations you're synthesizing
|
||||
4. Delete outdated observations - don't leave duplicates
|
||||
5. Quality over quantity - fewer good deductions beat many weak ones"""
|
||||
4. Empty or missing source_ids will be rejected
|
||||
5. Delete outdated observations - don't leave duplicates
|
||||
6. Quality over quantity - fewer good deductions beat many weak ones"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
|
|
@ -448,8 +475,11 @@ class InductionSpecialist(BaseSpecialist):
|
|||
if t["name"] not in PEER_CARD_TOOL_NAMES
|
||||
]
|
||||
|
||||
def get_model(self) -> str:
|
||||
return settings.DREAM.INDUCTION_MODEL
|
||||
def get_model_config(self) -> ConfiguredModelSettings:
|
||||
return _require_specialist_model_config(
|
||||
settings.DREAM.INDUCTION_MODEL_CONFIG,
|
||||
specialist_name="DREAM INDUCTION",
|
||||
)
|
||||
|
||||
def get_max_tokens(self) -> int:
|
||||
return 8192
|
||||
|
|
@ -514,11 +544,12 @@ Create inductive observations when you see patterns:
|
|||
|
||||
## CREATING OBSERVATIONS
|
||||
|
||||
Use `create_observations_inductive`.
|
||||
|
||||
```json
|
||||
{{
|
||||
"observations": [{{
|
||||
"content": "The pattern or generalization",
|
||||
"level": "inductive",
|
||||
"source_ids": ["id1", "id2", "id3"],
|
||||
"sources": ["evidence 1", "evidence 2"],
|
||||
"pattern_type": "tendency", // preference|behavior|personality|tendency|correlation
|
||||
|
|
@ -533,7 +564,8 @@ Create inductive observations when you see patterns:
|
|||
2. Don't just restate a single fact as a pattern
|
||||
3. Confidence based on evidence count: 2=low, 3-4=medium, 5+=high
|
||||
4. Look for HOW things change over time, not just static facts
|
||||
5. Include source_ids - always link back to evidence"""
|
||||
5. Include source_ids - always link back to evidence
|
||||
6. Empty or missing source_ids will be rejected"""
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ from typing import NamedTuple
|
|||
|
||||
import tiktoken
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .config import settings
|
||||
from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -26,49 +27,58 @@ class _EmbeddingClient:
|
|||
Embedding client supporting OpenAI and Gemini with chunking and batching support.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str | None = None, provider: str | None = None):
|
||||
self.provider: str = provider or settings.LLM.EMBEDDING_PROVIDER
|
||||
def __init__(
|
||||
self,
|
||||
config: EmbeddingModelConfig,
|
||||
*,
|
||||
vector_dimensions: int,
|
||||
max_input_tokens: int,
|
||||
max_tokens_per_request: int,
|
||||
):
|
||||
self.transport: str = config.transport
|
||||
self.model: str = config.model
|
||||
self.vector_dimensions: int = vector_dimensions
|
||||
|
||||
if self.provider == "gemini":
|
||||
if api_key is None:
|
||||
api_key = settings.LLM.GEMINI_API_KEY
|
||||
if not api_key:
|
||||
if self.transport == "gemini":
|
||||
if not config.api_key:
|
||||
raise ValueError("Gemini API key is required")
|
||||
self.client: genai.Client | AsyncOpenAI = genai.Client(api_key=api_key)
|
||||
self.model: str = "gemini-embedding-001"
|
||||
http_options = (
|
||||
genai_types.HttpOptions(base_url=config.base_url)
|
||||
if config.base_url
|
||||
else None
|
||||
)
|
||||
self.client: genai.Client | AsyncOpenAI = genai.Client(
|
||||
api_key=config.api_key,
|
||||
http_options=http_options,
|
||||
)
|
||||
# Gemini has a 2048 token limit
|
||||
self.max_embedding_tokens: int = min(settings.MAX_EMBEDDING_TOKENS, 2048)
|
||||
self.max_embedding_tokens: int = min(max_input_tokens, 2048)
|
||||
# Gemini batch size is not documented, using conservative estimate
|
||||
self.max_batch_size: int = 100
|
||||
elif self.provider == "openrouter":
|
||||
if api_key is None:
|
||||
api_key = settings.LLM.OPENAI_COMPATIBLE_API_KEY
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OpenRouter API key (LLM_OPENAI_COMPATIBLE_API_KEY) is required"
|
||||
)
|
||||
base_url = (
|
||||
settings.LLM.OPENAI_COMPATIBLE_BASE_URL
|
||||
or "https://openrouter.ai/api/v1"
|
||||
)
|
||||
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = "openai/text-embedding-3-small"
|
||||
self.max_embedding_tokens = settings.MAX_EMBEDDING_TOKENS
|
||||
self.max_batch_size = 2048 # Same as OpenAI
|
||||
else: # openai
|
||||
if api_key is None:
|
||||
api_key = settings.LLM.OPENAI_API_KEY
|
||||
if not api_key:
|
||||
if not config.api_key:
|
||||
raise ValueError("OpenAI API key is required")
|
||||
self.client = AsyncOpenAI(api_key=api_key)
|
||||
self.model = "text-embedding-3-small"
|
||||
self.max_embedding_tokens = settings.MAX_EMBEDDING_TOKENS
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
)
|
||||
self.max_embedding_tokens = max_input_tokens
|
||||
self.max_batch_size = 2048 # OpenAI batch limit
|
||||
|
||||
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("o200k_base")
|
||||
self.max_embedding_tokens_per_request: int = (
|
||||
settings.MAX_EMBEDDING_TOKENS_PER_REQUEST
|
||||
)
|
||||
self.max_embedding_tokens_per_request: int = max_tokens_per_request
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return self.transport
|
||||
|
||||
def _validate_embedding_dimensions(self, embedding: list[float]) -> list[float]:
|
||||
if len(embedding) != self.vector_dimensions:
|
||||
raise ValueError(
|
||||
f"Embedding dimension mismatch for {self.transport}:{self.model}. "
|
||||
+ f"Expected {self.vector_dimensions}, got {len(embedding)}."
|
||||
)
|
||||
return embedding
|
||||
|
||||
async def embed(self, query: str) -> list[float]:
|
||||
token_count = len(self.encoding.encode(query))
|
||||
|
|
@ -82,16 +92,16 @@ class _EmbeddingClient:
|
|||
response = await self.client.aio.models.embed_content(
|
||||
model=self.model,
|
||||
contents=query,
|
||||
config={"output_dimensionality": 1536},
|
||||
config={"output_dimensionality": self.vector_dimensions},
|
||||
)
|
||||
if not response.embeddings or not response.embeddings[0].values:
|
||||
raise ValueError("No embedding returned from Gemini API")
|
||||
return response.embeddings[0].values
|
||||
return self._validate_embedding_dimensions(response.embeddings[0].values)
|
||||
else: # openai
|
||||
response = await self.client.embeddings.create(
|
||||
model=self.model, input=query
|
||||
)
|
||||
return response.data[0].embedding
|
||||
return self._validate_embedding_dimensions(response.data[0].embedding)
|
||||
|
||||
async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
|
|
@ -116,18 +126,25 @@ class _EmbeddingClient:
|
|||
response = await self.client.aio.models.embed_content(
|
||||
model=self.model,
|
||||
contents=batch, # pyright: ignore[reportArgumentType]
|
||||
config={"output_dimensionality": 1536},
|
||||
config={"output_dimensionality": self.vector_dimensions},
|
||||
)
|
||||
if response.embeddings:
|
||||
for emb in response.embeddings:
|
||||
if emb.values:
|
||||
embeddings.append(emb.values)
|
||||
embeddings.append(
|
||||
self._validate_embedding_dimensions(emb.values)
|
||||
)
|
||||
else: # openai
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.model,
|
||||
)
|
||||
embeddings.extend([data.embedding for data in response.data])
|
||||
embeddings.extend(
|
||||
[
|
||||
self._validate_embedding_dimensions(data.embedding)
|
||||
for data in response.data
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
# Check if it's a token limit error and re-raise as ValueError for consistency
|
||||
if "token" in str(e).lower():
|
||||
|
|
@ -252,7 +269,7 @@ class _EmbeddingClient:
|
|||
response = await self.client.aio.models.embed_content(
|
||||
model=self.model,
|
||||
contents=[item.text for item in batch],
|
||||
config={"output_dimensionality": 1536},
|
||||
config={"output_dimensionality": self.vector_dimensions},
|
||||
)
|
||||
if response.embeddings:
|
||||
for item, embedding in zip(
|
||||
|
|
@ -260,15 +277,19 @@ class _EmbeddingClient:
|
|||
):
|
||||
if embedding.values:
|
||||
result[item.text_id][item.chunk_index] = (
|
||||
embedding.values
|
||||
self._validate_embedding_dimensions(
|
||||
embedding.values
|
||||
)
|
||||
)
|
||||
else: # openai / openrouter
|
||||
else: # openai
|
||||
response = await self.client.embeddings.create(
|
||||
model=self.model, input=[item.text for item in batch]
|
||||
)
|
||||
for item, embedding_data in zip(batch, response.data, strict=True):
|
||||
result[item.text_id][item.chunk_index] = (
|
||||
embedding_data.embedding
|
||||
self._validate_embedding_dimensions(
|
||||
embedding_data.embedding
|
||||
)
|
||||
)
|
||||
|
||||
return dict(result)
|
||||
|
|
@ -358,6 +379,7 @@ class EmbeddingClient:
|
|||
"""
|
||||
|
||||
_instance: "_EmbeddingClient | None" = None
|
||||
_instance_signature: tuple[object, ...] | None = None
|
||||
_lock: threading.Lock = threading.Lock()
|
||||
_wrapper_instance: "EmbeddingClient | None" = None
|
||||
|
||||
|
|
@ -374,26 +396,41 @@ class EmbeddingClient:
|
|||
|
||||
Uses double-checked locking for thread-safe lazy initialization.
|
||||
"""
|
||||
if self._instance is None:
|
||||
signature = self._get_settings_signature()
|
||||
if self._instance is None or self._instance_signature != signature:
|
||||
with self._lock:
|
||||
if self._instance is None:
|
||||
provider = settings.LLM.EMBEDDING_PROVIDER
|
||||
if provider == "gemini":
|
||||
api_key = settings.LLM.GEMINI_API_KEY
|
||||
elif provider == "openrouter":
|
||||
api_key = settings.LLM.OPENAI_COMPATIBLE_API_KEY
|
||||
else:
|
||||
api_key = settings.LLM.OPENAI_API_KEY
|
||||
|
||||
if self._instance is None or self._instance_signature != signature:
|
||||
runtime_config = self._resolve_runtime_config()
|
||||
self._instance = _EmbeddingClient(
|
||||
api_key=api_key, provider=provider
|
||||
runtime_config,
|
||||
vector_dimensions=settings.EMBEDDING.VECTOR_DIMENSIONS,
|
||||
max_input_tokens=settings.EMBEDDING.MAX_INPUT_TOKENS,
|
||||
max_tokens_per_request=settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
|
||||
)
|
||||
self._instance_signature = signature
|
||||
logger.debug(
|
||||
f"Initialized embedding client with provider: {provider}"
|
||||
"Initialized embedding client with transport: %s model: %s",
|
||||
runtime_config.transport,
|
||||
runtime_config.model,
|
||||
)
|
||||
|
||||
return self._instance
|
||||
|
||||
def _resolve_runtime_config(self) -> EmbeddingModelConfig:
|
||||
return resolve_embedding_model_config(settings.EMBEDDING.MODEL_CONFIG)
|
||||
|
||||
def _get_settings_signature(self) -> tuple[object, ...]:
|
||||
runtime_config = self._resolve_runtime_config()
|
||||
return (
|
||||
runtime_config.transport,
|
||||
runtime_config.model,
|
||||
runtime_config.api_key,
|
||||
runtime_config.base_url,
|
||||
settings.EMBEDDING.VECTOR_DIMENSIONS,
|
||||
settings.EMBEDDING.MAX_INPUT_TOKENS,
|
||||
settings.EMBEDDING.MAX_TOKENS_PER_REQUEST,
|
||||
)
|
||||
|
||||
async def embed(self, query: str) -> list[float]:
|
||||
"""Embed a single query string."""
|
||||
return await self._get_client().embed(query)
|
||||
|
|
@ -418,11 +455,21 @@ class EmbeddingClient:
|
|||
"""Get the model name."""
|
||||
return self._get_client().model
|
||||
|
||||
@property
|
||||
def transport(self) -> str:
|
||||
"""Get the transport name."""
|
||||
return self._get_client().transport
|
||||
|
||||
@property
|
||||
def max_embedding_tokens(self) -> int:
|
||||
"""Get the maximum embedding tokens."""
|
||||
return self._get_client().max_embedding_tokens
|
||||
|
||||
@property
|
||||
def vector_dimensions(self) -> int:
|
||||
"""Get the configured embedding dimensions."""
|
||||
return self._get_client().vector_dimensions
|
||||
|
||||
@property
|
||||
def encoding(self) -> tiktoken.Encoding:
|
||||
"""Get the tiktoken encoding."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
"""Honcho LLM orchestration package — stable public surface.
|
||||
|
||||
Application code should import from `src.llm` (or specific submodules like
|
||||
`src.llm.api` / `src.llm.types`). The old `src/utils/clients.py` entrypoint
|
||||
is gone; everything lives here now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .api import honcho_llm_call
|
||||
from .backend import CompletionResult, ProviderBackend, StreamChunk, ToolCallResult
|
||||
from .credentials import default_transport_api_key, resolve_credentials
|
||||
from .executor import honcho_llm_call_inner
|
||||
from .registry import (
|
||||
CLIENTS,
|
||||
backend_for_provider,
|
||||
client_for_model_config,
|
||||
get_anthropic_client,
|
||||
get_anthropic_override_client,
|
||||
get_backend,
|
||||
get_gemini_client,
|
||||
get_gemini_override_client,
|
||||
get_openai_client,
|
||||
get_openai_override_client,
|
||||
history_adapter_for_provider,
|
||||
)
|
||||
from .types import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
IterationCallback,
|
||||
IterationData,
|
||||
ProviderClient,
|
||||
ReasoningEffortType,
|
||||
StreamingResponseWithMetadata,
|
||||
VerbosityType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CLIENTS",
|
||||
"CompletionResult",
|
||||
"HonchoLLMCallResponse",
|
||||
"HonchoLLMCallStreamChunk",
|
||||
"IterationCallback",
|
||||
"IterationData",
|
||||
"ProviderBackend",
|
||||
"ProviderClient",
|
||||
"ReasoningEffortType",
|
||||
"StreamChunk",
|
||||
"StreamingResponseWithMetadata",
|
||||
"ToolCallResult",
|
||||
"VerbosityType",
|
||||
"backend_for_provider",
|
||||
"client_for_model_config",
|
||||
"default_transport_api_key",
|
||||
"get_anthropic_client",
|
||||
"get_anthropic_override_client",
|
||||
"get_backend",
|
||||
"get_gemini_client",
|
||||
"get_gemini_override_client",
|
||||
"get_openai_client",
|
||||
"get_openai_override_client",
|
||||
"history_adapter_for_provider",
|
||||
"honcho_llm_call",
|
||||
"honcho_llm_call_inner",
|
||||
"resolve_credentials",
|
||||
]
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
"""Public LLM entrypoint: `honcho_llm_call`.
|
||||
|
||||
Orchestrates:
|
||||
- Runtime config resolution from ConfiguredModelSettings → ModelConfig.
|
||||
- Per-attempt planning (primary vs fallback selection).
|
||||
- Retry with exponential backoff via tenacity.
|
||||
- Tool-loop delegation when tools are supplied.
|
||||
- Single-call delegation to the executor otherwise.
|
||||
- Reasoning-trace telemetry emission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import Any, Literal, TypeVar, cast, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sentry_sdk.ai.monitoring import ai_track
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from src.config import ConfiguredModelSettings, ModelConfig
|
||||
from src.exceptions import ValidationException
|
||||
from src.telemetry.logging import conditional_observe
|
||||
from src.telemetry.reasoning_traces import log_reasoning_trace
|
||||
|
||||
from .executor import honcho_llm_call_inner
|
||||
from .runtime import (
|
||||
AttemptPlan,
|
||||
current_attempt,
|
||||
effective_temperature,
|
||||
plan_attempt,
|
||||
resolve_runtime_model_config,
|
||||
)
|
||||
from .tool_loop import execute_tool_loop
|
||||
from .types import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
IterationCallback,
|
||||
ReasoningEffortType,
|
||||
StreamingResponseWithMetadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
M = TypeVar("M", bound=BaseModel)
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call(
|
||||
*,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[M],
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
enable_retry: bool = True,
|
||||
retry_attempts: int = 3,
|
||||
stream: Literal[False] = False,
|
||||
stream_final_only: bool = False,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
tool_executor: Callable[[str, dict[str, Any]], Any] | None = None,
|
||||
max_tool_iterations: int = 10,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
max_input_tokens: int | None = None,
|
||||
trace_name: str | None = None,
|
||||
iteration_callback: IterationCallback | None = None,
|
||||
) -> HonchoLLMCallResponse[M]: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call(
|
||||
*,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
enable_retry: bool = True,
|
||||
retry_attempts: int = 3,
|
||||
stream: Literal[False] = False,
|
||||
stream_final_only: bool = False,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
tool_executor: Callable[[str, dict[str, Any]], Any] | None = None,
|
||||
max_tool_iterations: int = 10,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
max_input_tokens: int | None = None,
|
||||
trace_name: str | None = None,
|
||||
iteration_callback: IterationCallback | None = None,
|
||||
) -> HonchoLLMCallResponse[str]: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call(
|
||||
*,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
enable_retry: bool = True,
|
||||
retry_attempts: int = 3,
|
||||
stream: Literal[True] = ...,
|
||||
stream_final_only: bool = False,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
tool_executor: Callable[[str, dict[str, Any]], Any] | None = None,
|
||||
max_tool_iterations: int = 10,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
max_input_tokens: int | None = None,
|
||||
trace_name: str | None = None,
|
||||
iteration_callback: IterationCallback | None = None,
|
||||
) -> AsyncIterator[HonchoLLMCallStreamChunk] | StreamingResponseWithMetadata: ...
|
||||
|
||||
|
||||
@conditional_observe(name="LLM Call")
|
||||
async def honcho_llm_call(
|
||||
*,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
enable_retry: bool = True,
|
||||
retry_attempts: int = 3,
|
||||
stream: bool = False,
|
||||
stream_final_only: bool = False,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
tool_executor: Callable[[str, dict[str, Any]], Any] | None = None,
|
||||
max_tool_iterations: int = 10,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
max_input_tokens: int | None = None,
|
||||
trace_name: str | None = None,
|
||||
iteration_callback: IterationCallback | None = None,
|
||||
) -> (
|
||||
HonchoLLMCallResponse[Any]
|
||||
| AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
| StreamingResponseWithMetadata
|
||||
):
|
||||
"""Make an LLM call with retry, optional backup failover, and optional tool loop.
|
||||
|
||||
Backup provider/model (if configured on the primary ModelConfig's
|
||||
`fallback`) is used on the final retry attempt, which is 3 by default.
|
||||
|
||||
Raises:
|
||||
ValidationException: If streaming and tool calling are combined
|
||||
without `stream_final_only=True`.
|
||||
"""
|
||||
runtime_model_config = resolve_runtime_model_config(model_config)
|
||||
|
||||
# Caller kwargs left at None are resolved downstream by
|
||||
# effective_config_for_call against whichever ModelConfig wins the
|
||||
# attempt (primary or fallback). Defaulting here from
|
||||
# runtime_model_config would clobber a fallback config's own
|
||||
# temperature/thinking params on the final retry, so we deliberately
|
||||
# keep the locals as the caller supplied them.
|
||||
|
||||
if stream and tools and not stream_final_only:
|
||||
raise ValidationException(
|
||||
"Streaming is not supported with tool calling. "
|
||||
+ "Set stream=False when using tools, or use stream_final_only=True "
|
||||
+ "to stream only the final response after tool calls."
|
||||
)
|
||||
|
||||
# tenacity uses 1-indexed attempts.
|
||||
current_attempt.set(1)
|
||||
|
||||
def _get_attempt_plan() -> AttemptPlan:
|
||||
return plan_attempt(
|
||||
runtime_model_config=runtime_model_config,
|
||||
attempt=current_attempt.get(),
|
||||
retry_attempts=retry_attempts,
|
||||
call_thinking_budget_tokens=thinking_budget_tokens,
|
||||
call_reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
async def _call_with_provider_selection() -> (
|
||||
HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
):
|
||||
"""Select provider/model based on current attempt, then call once.
|
||||
|
||||
This closure is what tenacity wraps, so selection re-runs per attempt
|
||||
(and the fallback kicks in on the final attempt automatically).
|
||||
"""
|
||||
plan = _get_attempt_plan()
|
||||
|
||||
if stream:
|
||||
return await honcho_llm_call_inner(
|
||||
plan.provider,
|
||||
plan.model,
|
||||
prompt,
|
||||
max_tokens,
|
||||
response_model,
|
||||
json_mode,
|
||||
effective_temperature(temperature),
|
||||
stop_seqs,
|
||||
plan.reasoning_effort,
|
||||
verbosity,
|
||||
plan.thinking_budget_tokens,
|
||||
stream=True,
|
||||
client_override=plan.client,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
selected_config=plan.selected_config,
|
||||
)
|
||||
return await honcho_llm_call_inner(
|
||||
plan.provider,
|
||||
plan.model,
|
||||
prompt,
|
||||
max_tokens,
|
||||
response_model,
|
||||
json_mode,
|
||||
effective_temperature(temperature),
|
||||
stop_seqs,
|
||||
plan.reasoning_effort,
|
||||
verbosity,
|
||||
plan.thinking_budget_tokens,
|
||||
stream=False,
|
||||
client_override=plan.client,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
selected_config=plan.selected_config,
|
||||
)
|
||||
|
||||
decorated = _call_with_provider_selection
|
||||
|
||||
if track_name:
|
||||
decorated = ai_track(track_name)(decorated)
|
||||
|
||||
def before_retry_callback(retry_state: Any) -> None:
|
||||
"""Update attempt counter before each retry + log transient failures.
|
||||
|
||||
tenacity's before_sleep fires AFTER an attempt fails, BEFORE sleeping,
|
||||
so we increment to the next attempt number here.
|
||||
"""
|
||||
next_attempt = retry_state.attempt_number + 1
|
||||
current_attempt.set(next_attempt)
|
||||
exc = retry_state.outcome.exception() if retry_state.outcome else None
|
||||
if exc:
|
||||
logger.warning(
|
||||
f"Error on attempt {retry_state.attempt_number}/{retry_attempts} with "
|
||||
+ f"{runtime_model_config.transport}/{runtime_model_config.model}: {exc}"
|
||||
)
|
||||
logger.info(f"Will retry with attempt {next_attempt}/{retry_attempts}")
|
||||
|
||||
if enable_retry:
|
||||
decorated = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
before_sleep=before_retry_callback,
|
||||
)(decorated)
|
||||
|
||||
def _trace_thinking_budget() -> int | None:
|
||||
# Trace log should reflect what got applied, so fall back to the
|
||||
# runtime config's value when the caller left the kwarg unset.
|
||||
return (
|
||||
thinking_budget_tokens
|
||||
if thinking_budget_tokens is not None
|
||||
else runtime_model_config.thinking_budget_tokens
|
||||
)
|
||||
|
||||
def _trace_reasoning_effort() -> ReasoningEffortType:
|
||||
if reasoning_effort is not None:
|
||||
return reasoning_effort
|
||||
config_effort = runtime_model_config.thinking_effort
|
||||
return cast(ReasoningEffortType, config_effort) if config_effort else None
|
||||
|
||||
def _trace_stop_seqs() -> list[str] | None:
|
||||
return (
|
||||
stop_seqs if stop_seqs is not None else runtime_model_config.stop_sequences
|
||||
)
|
||||
|
||||
# Tool-less path: call once and return.
|
||||
if not tools or not tool_executor:
|
||||
result: (
|
||||
HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
) = await decorated()
|
||||
if trace_name and isinstance(result, HonchoLLMCallResponse):
|
||||
log_reasoning_trace(
|
||||
task_type=trace_name,
|
||||
model_config=runtime_model_config,
|
||||
prompt=prompt,
|
||||
response=result,
|
||||
max_tokens=max_tokens,
|
||||
thinking_budget_tokens=_trace_thinking_budget(),
|
||||
reasoning_effort=_trace_reasoning_effort(),
|
||||
json_mode=json_mode,
|
||||
stop_seqs=_trace_stop_seqs(),
|
||||
messages=messages,
|
||||
)
|
||||
return result
|
||||
|
||||
# execute_tool_loop raises ValidationException on out-of-range
|
||||
# max_tool_iterations; fail-fast is cheaper than silent clamping here.
|
||||
result = await execute_tool_loop(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
tool_executor=tool_executor,
|
||||
max_tool_iterations=max_tool_iterations,
|
||||
response_model=response_model,
|
||||
json_mode=json_mode,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
verbosity=verbosity,
|
||||
enable_retry=enable_retry,
|
||||
retry_attempts=retry_attempts,
|
||||
max_input_tokens=max_input_tokens,
|
||||
get_attempt_plan=_get_attempt_plan,
|
||||
before_retry_callback=before_retry_callback,
|
||||
stream_final=stream_final_only,
|
||||
iteration_callback=iteration_callback,
|
||||
)
|
||||
if trace_name and isinstance(result, HonchoLLMCallResponse):
|
||||
log_reasoning_trace(
|
||||
task_type=trace_name,
|
||||
model_config=runtime_model_config,
|
||||
prompt=prompt,
|
||||
response=result,
|
||||
max_tokens=max_tokens,
|
||||
thinking_budget_tokens=_trace_thinking_budget(),
|
||||
reasoning_effort=_trace_reasoning_effort(),
|
||||
json_mode=json_mode,
|
||||
stop_seqs=_trace_stop_seqs(),
|
||||
messages=messages,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["honcho_llm_call"]
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallResult:
|
||||
"""Normalized tool call from any provider."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
thought_signature: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CompletionResult:
|
||||
"""Normalized completion result returned by provider backends."""
|
||||
|
||||
content: Any = ""
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_creation_input_tokens: int = 0
|
||||
cache_read_input_tokens: int = 0
|
||||
finish_reason: str = "stop"
|
||||
tool_calls: list[ToolCallResult] = field(default_factory=list)
|
||||
thinking_content: str | None = None
|
||||
thinking_blocks: list[dict[str, Any]] = field(default_factory=list)
|
||||
reasoning_details: list[dict[str, Any]] = field(default_factory=list)
|
||||
raw_response: Any = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamChunk:
|
||||
"""A single chunk in a streaming response."""
|
||||
|
||||
content: str = ""
|
||||
is_done: bool = False
|
||||
finish_reason: str | None = None
|
||||
output_tokens: int | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ProviderBackend(Protocol):
|
||||
"""Transport-agnostic interface for LLM providers.
|
||||
|
||||
Credentials are baked into the underlying SDK client at backend construction
|
||||
time (see src/llm/registry.py), so these method signatures deliberately do
|
||||
not accept api_key / api_base.
|
||||
"""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> CompletionResult: ...
|
||||
|
||||
def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[StreamChunk]: ...
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
from .anthropic import AnthropicBackend
|
||||
from .gemini import GeminiBackend
|
||||
from .openai import OpenAIBackend
|
||||
|
||||
__all__ = [
|
||||
"AnthropicBackend",
|
||||
"GeminiBackend",
|
||||
"OpenAIBackend",
|
||||
]
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
|
||||
|
||||
class AnthropicBackend:
|
||||
"""Provider backend wrapping the native Anthropic SDK."""
|
||||
|
||||
def __init__(self, client: Any) -> None:
|
||||
self._client: Any = client
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> CompletionResult:
|
||||
del max_output_tokens
|
||||
if thinking_effort is not None:
|
||||
raise ValueError(
|
||||
"Anthropic backend does not support thinking_effort; use thinking_budget_tokens instead"
|
||||
)
|
||||
|
||||
request_messages, system_messages = self._extract_system(messages)
|
||||
params: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": request_messages,
|
||||
}
|
||||
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
if stop:
|
||||
params["stop_sequences"] = stop
|
||||
if system_messages:
|
||||
params["system"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "\n\n".join(system_messages),
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
converted_tool_choice = self._convert_tool_choice(tool_choice)
|
||||
if converted_tool_choice is not None:
|
||||
params["tool_choice"] = converted_tool_choice
|
||||
if thinking_budget_tokens:
|
||||
params["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": thinking_budget_tokens,
|
||||
}
|
||||
if extra_params:
|
||||
for key in ("top_p", "top_k"):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
|
||||
use_json_prefill = (
|
||||
bool(response_format or self._json_mode(extra_params))
|
||||
and not thinking_budget_tokens
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
)
|
||||
|
||||
response = await self._client.messages.create(**params)
|
||||
return self._normalize_response(
|
||||
response=response,
|
||||
response_format=response_format
|
||||
if isinstance(response_format, type)
|
||||
else None,
|
||||
prefilled_json=use_json_prefill,
|
||||
model_name=model,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
is_json_mode = self._json_mode(extra_params)
|
||||
del max_output_tokens
|
||||
if thinking_effort is not None:
|
||||
raise ValueError(
|
||||
"Anthropic backend does not support thinking_effort; use thinking_budget_tokens instead"
|
||||
)
|
||||
|
||||
request_messages, system_messages = self._extract_system(messages)
|
||||
params: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": request_messages,
|
||||
}
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
if stop:
|
||||
params["stop_sequences"] = stop
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
converted_tool_choice = self._convert_tool_choice(tool_choice)
|
||||
if converted_tool_choice is not None:
|
||||
params["tool_choice"] = converted_tool_choice
|
||||
if system_messages:
|
||||
params["system"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "\n\n".join(system_messages),
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
if extra_params:
|
||||
for key in ("top_p", "top_k"):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
use_json_prefill = (
|
||||
bool(response_format or is_json_mode)
|
||||
and not thinking_budget_tokens
|
||||
and self._supports_assistant_prefill(model)
|
||||
)
|
||||
if use_json_prefill and params["messages"]:
|
||||
if response_format and isinstance(response_format, type):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
)
|
||||
params["messages"].append({"role": "assistant", "content": "{"})
|
||||
elif (
|
||||
response_format and isinstance(response_format, type) and params["messages"]
|
||||
):
|
||||
schema_json = json.dumps(response_format.model_json_schema(), indent=2)
|
||||
self._append_text_to_last_message(
|
||||
params["messages"],
|
||||
f"\n\nRespond with valid JSON matching this schema:\n{schema_json}",
|
||||
)
|
||||
if thinking_budget_tokens:
|
||||
params["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": thinking_budget_tokens,
|
||||
}
|
||||
|
||||
async with self._client.messages.stream(**params) as stream:
|
||||
async for chunk in stream:
|
||||
if (
|
||||
chunk.type == "content_block_delta"
|
||||
and hasattr(chunk, "delta")
|
||||
and hasattr(chunk.delta, "text")
|
||||
):
|
||||
yield StreamChunk(content=getattr(chunk.delta, "text", ""))
|
||||
|
||||
final_message = await stream.get_final_message()
|
||||
output_tokens = (
|
||||
final_message.usage.output_tokens if final_message.usage else None
|
||||
)
|
||||
yield StreamChunk(
|
||||
is_done=True,
|
||||
finish_reason=final_message.stop_reason,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
def _normalize_response(
|
||||
self,
|
||||
*,
|
||||
response: Any,
|
||||
response_format: type[BaseModel] | None,
|
||||
prefilled_json: bool,
|
||||
model_name: str,
|
||||
) -> CompletionResult:
|
||||
text_blocks: list[str] = []
|
||||
thinking_text_blocks: list[str] = []
|
||||
thinking_full_blocks: list[dict[str, Any]] = []
|
||||
tool_calls: list[ToolCallResult] = []
|
||||
|
||||
for block in response.content:
|
||||
if isinstance(block, TextBlock):
|
||||
text_blocks.append(block.text)
|
||||
elif isinstance(block, ThinkingBlock):
|
||||
thinking_text_blocks.append(block.thinking)
|
||||
thinking_full_blocks.append(
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": block.thinking,
|
||||
"signature": block.signature,
|
||||
}
|
||||
)
|
||||
elif isinstance(block, ToolUseBlock):
|
||||
tool_calls.append(
|
||||
ToolCallResult(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
input=dict(block.input),
|
||||
)
|
||||
)
|
||||
|
||||
usage = response.usage
|
||||
cache_creation_tokens = (
|
||||
getattr(usage, "cache_creation_input_tokens", 0) or 0 if usage else 0
|
||||
)
|
||||
cache_read_tokens = (
|
||||
getattr(usage, "cache_read_input_tokens", 0) or 0 if usage else 0
|
||||
)
|
||||
uncached_tokens = usage.input_tokens if usage else 0
|
||||
total_input_tokens = uncached_tokens + cache_creation_tokens + cache_read_tokens
|
||||
|
||||
text_content = "\n".join(text_blocks)
|
||||
thinking_content = (
|
||||
"\n".join(thinking_text_blocks) if thinking_text_blocks else None
|
||||
)
|
||||
|
||||
content: Any = text_content
|
||||
if response_format is not None:
|
||||
raw_content = f"{{{text_content}" if prefilled_json else text_content
|
||||
try:
|
||||
if prefilled_json:
|
||||
parsed_json = json.loads(raw_content)
|
||||
content = response_format.model_validate(parsed_json)
|
||||
else:
|
||||
content = response_format.model_validate_json(raw_content)
|
||||
except (json.JSONDecodeError, ValidationError, ValueError):
|
||||
content = repair_response_model_json(
|
||||
raw_content,
|
||||
response_format,
|
||||
model_name,
|
||||
)
|
||||
|
||||
return CompletionResult(
|
||||
content=content,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=usage.output_tokens if usage else 0,
|
||||
cache_creation_input_tokens=cache_creation_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
finish_reason=response.stop_reason or "stop",
|
||||
tool_calls=tool_calls,
|
||||
thinking_content=thinking_content,
|
||||
thinking_blocks=thinking_full_blocks,
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _supports_assistant_prefill(model: str) -> bool:
|
||||
# Claude 4-class models reject assistant-prefill and require the
|
||||
# conversation to end with a user message.
|
||||
return not model.startswith(
|
||||
(
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4",
|
||||
"claude-haiku-4",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_system(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
system_messages: list[str] = []
|
||||
non_system_messages: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "system" and isinstance(
|
||||
message.get("content"),
|
||||
str,
|
||||
):
|
||||
system_messages.append(message["content"])
|
||||
else:
|
||||
non_system_messages.append(copy.deepcopy(message))
|
||||
return non_system_messages, system_messages
|
||||
|
||||
@staticmethod
|
||||
def _convert_tool_choice(
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, dict):
|
||||
return tool_choice
|
||||
if tool_choice == "auto":
|
||||
return {"type": "auto"}
|
||||
if tool_choice in {"any", "required"}:
|
||||
return {"type": "any"}
|
||||
if tool_choice == "none":
|
||||
return {"type": "none"}
|
||||
return {"type": "tool", "name": tool_choice}
|
||||
|
||||
@staticmethod
|
||||
def _append_text_to_last_message(
|
||||
messages: list[dict[str, Any]], suffix: str
|
||||
) -> None:
|
||||
"""Append text to the last message, handling both string and list content."""
|
||||
last = messages[-1]
|
||||
content = last.get("content")
|
||||
if isinstance(content, str):
|
||||
last["content"] = content + suffix
|
||||
elif isinstance(content, list):
|
||||
# Content block list — append to the last text block or add one
|
||||
blocks: list[dict[str, Any]] = content # pyright: ignore[reportUnknownVariableType]
|
||||
for block in reversed(blocks):
|
||||
if block.get("type") == "text":
|
||||
block["text"] = block["text"] + suffix
|
||||
return
|
||||
blocks.append({"type": "text", "text": suffix})
|
||||
|
||||
@staticmethod
|
||||
def _json_mode(extra_params: dict[str, Any] | None) -> bool:
|
||||
return bool(extra_params and extra_params.get("json_mode"))
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.exceptions import LLMError, ValidationException
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.caching import (
|
||||
GeminiCacheHandle,
|
||||
PromptCachePolicy,
|
||||
build_cache_key,
|
||||
gemini_cache_store,
|
||||
)
|
||||
from src.llm.structured_output import repair_response_model_json
|
||||
|
||||
GEMINI_BLOCKED_FINISH_REASONS = {
|
||||
"SAFETY",
|
||||
"RECITATION",
|
||||
"PROHIBITED_CONTENT",
|
||||
"BLOCKLIST",
|
||||
}
|
||||
|
||||
|
||||
class GeminiBackend:
|
||||
"""Provider backend wrapping the Google GenAI SDK."""
|
||||
|
||||
def __init__(self, client: Any) -> None:
|
||||
self._client: Any = client
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> CompletionResult:
|
||||
contents, system_instruction = self._convert_messages(messages)
|
||||
config = self._build_config(
|
||||
max_tokens=max_output_tokens or max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_format,
|
||||
thinking_budget_tokens=thinking_budget_tokens,
|
||||
thinking_effort=thinking_effort,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
if extra_params and "cache_policy" in extra_params
|
||||
else None
|
||||
)
|
||||
if isinstance(cache_policy, PromptCachePolicy) and isinstance(contents, list):
|
||||
# Cache the history prefix; only the last turn is sent as new input.
|
||||
cacheable = contents[:-1] if contents else []
|
||||
await self._attach_cached_content(
|
||||
model=model,
|
||||
config=config,
|
||||
cache_policy=cache_policy,
|
||||
contents=cacheable,
|
||||
tools=tools,
|
||||
)
|
||||
if "cached_content" in config and contents:
|
||||
contents = contents[-1:]
|
||||
|
||||
if isinstance(contents, list) and not contents:
|
||||
raise LLMError(
|
||||
"No non-system messages to send to Gemini",
|
||||
provider="gemini",
|
||||
model=model,
|
||||
)
|
||||
|
||||
response = await self._client.aio.models.generate_content(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=config or None,
|
||||
)
|
||||
return self._normalize_response(
|
||||
response=response,
|
||||
response_format=response_format
|
||||
if isinstance(response_format, type)
|
||||
else None,
|
||||
model_name=model,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
contents, system_instruction = self._convert_messages(messages)
|
||||
config = self._build_config(
|
||||
max_tokens=max_output_tokens or max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_format,
|
||||
thinking_budget_tokens=thinking_budget_tokens,
|
||||
thinking_effort=thinking_effort,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
if system_instruction:
|
||||
config["system_instruction"] = system_instruction
|
||||
|
||||
cache_policy = (
|
||||
extra_params.get("cache_policy")
|
||||
if extra_params and "cache_policy" in extra_params
|
||||
else None
|
||||
)
|
||||
if isinstance(cache_policy, PromptCachePolicy) and isinstance(contents, list):
|
||||
# Cache the history prefix; only the last turn is sent as new input.
|
||||
cacheable = contents[:-1] if contents else []
|
||||
await self._attach_cached_content(
|
||||
model=model,
|
||||
config=config,
|
||||
cache_policy=cache_policy,
|
||||
contents=cacheable,
|
||||
tools=tools,
|
||||
)
|
||||
if "cached_content" in config and contents:
|
||||
contents = contents[-1:]
|
||||
|
||||
if isinstance(contents, list) and not contents:
|
||||
raise LLMError(
|
||||
"No non-system messages to send to Gemini",
|
||||
provider="gemini",
|
||||
model=model,
|
||||
)
|
||||
|
||||
stream = await self._client.aio.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=config or None,
|
||||
)
|
||||
|
||||
final_chunk = None
|
||||
any_text = False
|
||||
async for chunk in stream:
|
||||
if chunk.text:
|
||||
any_text = True
|
||||
yield StreamChunk(content=chunk.text)
|
||||
final_chunk = chunk
|
||||
|
||||
finish_reason = "stop"
|
||||
output_tokens: int | None = None
|
||||
if (
|
||||
final_chunk
|
||||
and getattr(final_chunk, "candidates", None)
|
||||
and final_chunk.candidates[0].finish_reason
|
||||
):
|
||||
finish_reason = final_chunk.candidates[0].finish_reason.name
|
||||
if (
|
||||
final_chunk
|
||||
and getattr(final_chunk, "usage_metadata", None)
|
||||
and getattr(final_chunk.usage_metadata, "candidates_token_count", None)
|
||||
):
|
||||
output_tokens = final_chunk.usage_metadata.candidates_token_count or None
|
||||
|
||||
# Mirror complete()'s behavior on SAFETY / RECITATION / etc. — if
|
||||
# Gemini blocked the response and produced no usable text, raise
|
||||
# LLMError rather than silently yielding a terminal chunk carrying
|
||||
# the blocked finish_reason. Downstream callers should get a clean
|
||||
# exception and a chance to retry / fall back.
|
||||
if not any_text and finish_reason in GEMINI_BLOCKED_FINISH_REASONS:
|
||||
raise LLMError(
|
||||
f"Gemini response blocked (finish_reason={finish_reason})",
|
||||
provider="gemini",
|
||||
model=model,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
yield StreamChunk(
|
||||
is_done=True,
|
||||
finish_reason=finish_reason,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
def _build_config(
|
||||
self,
|
||||
*,
|
||||
max_tokens: int,
|
||||
temperature: float | None,
|
||||
stop: list[str] | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None,
|
||||
thinking_budget_tokens: int | None,
|
||||
thinking_effort: str | None,
|
||||
extra_params: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
config: dict[str, Any] = {
|
||||
"max_output_tokens": max_tokens,
|
||||
}
|
||||
if temperature is not None:
|
||||
config["temperature"] = temperature
|
||||
if stop:
|
||||
config["stop_sequences"] = stop
|
||||
if tools:
|
||||
config["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
config["tool_config"] = self._convert_tool_choice(tool_choice)
|
||||
if response_format is not None:
|
||||
config["response_mime_type"] = "application/json"
|
||||
config["response_schema"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode") and not tools:
|
||||
config["response_mime_type"] = "application/json"
|
||||
thinking_config: dict[str, Any] = {}
|
||||
if thinking_budget_tokens is not None:
|
||||
thinking_config["thinking_budget"] = thinking_budget_tokens
|
||||
if thinking_effort is not None:
|
||||
thinking_config["thinking_level"] = thinking_effort
|
||||
if len(thinking_config) > 1:
|
||||
raise ValidationException(
|
||||
"Gemini backend does not support sending both thinking_budget_tokens and thinking_effort in the same request"
|
||||
)
|
||||
if thinking_config:
|
||||
config["thinking_config"] = thinking_config
|
||||
for key in ("top_p", "top_k", "frequency_penalty", "presence_penalty", "seed"):
|
||||
if extra_params and key in extra_params:
|
||||
config[key] = extra_params[key]
|
||||
return config
|
||||
|
||||
def _normalize_response(
|
||||
self,
|
||||
*,
|
||||
response: Any,
|
||||
response_format: type[BaseModel] | None,
|
||||
model_name: str,
|
||||
) -> CompletionResult:
|
||||
candidate = response.candidates[0] if response.candidates else None
|
||||
finish_reason = (
|
||||
candidate.finish_reason.name
|
||||
if candidate is not None and candidate.finish_reason
|
||||
else "stop"
|
||||
)
|
||||
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[ToolCallResult] = []
|
||||
candidate_parts = (
|
||||
cast(list[Any] | None, getattr(candidate.content, "parts", None))
|
||||
if candidate is not None and getattr(candidate, "content", None)
|
||||
else None
|
||||
)
|
||||
if isinstance(candidate_parts, list):
|
||||
for part in candidate_parts:
|
||||
part_text = getattr(part, "text", None)
|
||||
if isinstance(part_text, str) and part_text:
|
||||
text_parts.append(part_text)
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call is not None:
|
||||
function_name = getattr(function_call, "name", None)
|
||||
function_args = getattr(function_call, "args", None)
|
||||
if not isinstance(function_name, str):
|
||||
continue
|
||||
tool_calls.append(
|
||||
ToolCallResult(
|
||||
id=f"call_{function_name}_{len(tool_calls)}",
|
||||
name=function_name,
|
||||
input=dict(cast(dict[str, Any], function_args))
|
||||
if function_args
|
||||
else {},
|
||||
thought_signature=getattr(part, "thought_signature", None),
|
||||
)
|
||||
)
|
||||
response_text = getattr(response, "text", None)
|
||||
if not text_parts and isinstance(response_text, str) and response_text:
|
||||
text_parts.append(response_text)
|
||||
response_function_calls = cast(
|
||||
list[Any] | None,
|
||||
getattr(response, "function_calls", None),
|
||||
)
|
||||
if not tool_calls and isinstance(response_function_calls, list):
|
||||
for function_call in response_function_calls:
|
||||
function_name = getattr(function_call, "name", None)
|
||||
function_args = getattr(function_call, "args", None)
|
||||
if not isinstance(function_name, str):
|
||||
continue
|
||||
tool_calls.append(
|
||||
ToolCallResult(
|
||||
id=f"call_{function_name}_{len(tool_calls)}",
|
||||
name=function_name,
|
||||
input=dict(cast(dict[str, Any], function_args))
|
||||
if function_args
|
||||
else {},
|
||||
)
|
||||
)
|
||||
|
||||
content: Any = "\n".join(text_parts) if text_parts else ""
|
||||
if response_format is not None:
|
||||
parsed_response = getattr(response, "parsed", None)
|
||||
if isinstance(parsed_response, response_format):
|
||||
content = parsed_response
|
||||
elif isinstance(parsed_response, dict):
|
||||
content = response_format.model_validate(parsed_response)
|
||||
elif isinstance(parsed_response, str):
|
||||
content = response_format.model_validate_json(parsed_response)
|
||||
else:
|
||||
if finish_reason in GEMINI_BLOCKED_FINISH_REASONS:
|
||||
raise LLMError(
|
||||
f"Gemini response blocked (finish_reason={finish_reason})",
|
||||
provider="gemini",
|
||||
model=model_name,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
raw_text = "".join(text_parts)
|
||||
content = repair_response_model_json(
|
||||
raw_text,
|
||||
response_format,
|
||||
model_name,
|
||||
)
|
||||
elif (
|
||||
not content
|
||||
and not tool_calls
|
||||
and finish_reason in GEMINI_BLOCKED_FINISH_REASONS
|
||||
):
|
||||
raise LLMError(
|
||||
f"Gemini response blocked (finish_reason={finish_reason})",
|
||||
provider="gemini",
|
||||
model=model_name,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
usage = response.usage_metadata
|
||||
cache_read_input_tokens = 0
|
||||
if usage is not None:
|
||||
cached_tokens = getattr(usage, "cached_content_token_count", 0)
|
||||
if isinstance(cached_tokens, int):
|
||||
cache_read_input_tokens = cached_tokens
|
||||
return CompletionResult(
|
||||
content=content,
|
||||
input_tokens=usage.prompt_token_count if usage else 0,
|
||||
output_tokens=usage.candidates_token_count if usage else 0,
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
finish_reason=finish_reason,
|
||||
tool_calls=tool_calls,
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
async def _attach_cached_content(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
config: dict[str, Any],
|
||||
cache_policy: PromptCachePolicy,
|
||||
contents: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
) -> None:
|
||||
if cache_policy.mode != "gemini_cached_content":
|
||||
return
|
||||
# Worth caching if there are history messages, system instruction, or tools
|
||||
has_cacheable = bool(
|
||||
contents or config.get("system_instruction") or config.get("tools")
|
||||
)
|
||||
if not has_cacheable:
|
||||
return
|
||||
|
||||
cache_key = build_cache_key(
|
||||
config=self._cache_model_config(model),
|
||||
cache_policy=cache_policy,
|
||||
cacheable_messages=contents,
|
||||
tools=tools,
|
||||
system_instruction=config.get("system_instruction"),
|
||||
tool_config=config.get("tool_config"),
|
||||
)
|
||||
cached_handle = gemini_cache_store.get(cache_key)
|
||||
if cached_handle is None:
|
||||
ttl_seconds = cache_policy.ttl_seconds or 300
|
||||
cache_config: dict[str, Any] = {
|
||||
"system_instruction": config.get("system_instruction"),
|
||||
"tools": config.get("tools"),
|
||||
"tool_config": config.get("tool_config"),
|
||||
"ttl": f"{ttl_seconds}s",
|
||||
}
|
||||
if contents:
|
||||
cache_config["contents"] = contents
|
||||
cached_content = await self._client.aio.caches.create(
|
||||
model=model,
|
||||
config=cache_config,
|
||||
)
|
||||
expires_at = getattr(cached_content, "expire_time", None)
|
||||
if expires_at is None:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
cached_handle = gemini_cache_store.set(
|
||||
GeminiCacheHandle(
|
||||
key=cache_key,
|
||||
cached_content_name=cached_content.name,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
# Once a cached-content handle is attached, Gemini rejects repeating
|
||||
# system/tool configuration on the generate call.
|
||||
config.pop("system_instruction", None)
|
||||
config.pop("tools", None)
|
||||
config.pop("tool_config", None)
|
||||
config["cached_content"] = cached_handle.cached_content_name
|
||||
|
||||
@staticmethod
|
||||
def _cache_model_config(model: str):
|
||||
from src.config import ModelConfig
|
||||
|
||||
return ModelConfig(transport="gemini", model=model)
|
||||
|
||||
@staticmethod
|
||||
def _convert_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]] | str, str | None]:
|
||||
system_messages: list[str] = []
|
||||
contents: list[dict[str, Any]] = []
|
||||
|
||||
for message in messages:
|
||||
role = message.get("role", "user")
|
||||
if role == "system":
|
||||
if isinstance(message.get("content"), str):
|
||||
system_messages.append(message["content"])
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
role = "model"
|
||||
|
||||
if isinstance(message.get("parts"), list):
|
||||
message_copy = message.copy()
|
||||
message_copy["role"] = role
|
||||
contents.append(message_copy)
|
||||
continue
|
||||
|
||||
if isinstance(message.get("content"), str):
|
||||
contents.append({"role": role, "parts": [{"text": message["content"]}]})
|
||||
continue
|
||||
|
||||
if isinstance(message.get("content"), list):
|
||||
parts: list[dict[str, Any]] = []
|
||||
for block in message["content"]:
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
parts.append({"text": block["text"]})
|
||||
else:
|
||||
# Silently dropping non-"text" blocks would mask real
|
||||
# input-shape bugs — e.g., an Anthropic-shaped
|
||||
# tool_use/tool_result payload accidentally routed to
|
||||
# the Gemini backend without going through the
|
||||
# history adapter. Fail fast so the caller knows.
|
||||
raise ValidationException(
|
||||
"Gemini backend cannot translate content block "
|
||||
+ f"of type {block_type!r}; translate to "
|
||||
+ "Gemini-native 'parts' via the history adapter "
|
||||
+ "before passing to the backend"
|
||||
)
|
||||
if parts:
|
||||
contents.append({"role": role, "parts": parts})
|
||||
|
||||
system_instruction = "\n\n".join(system_messages) if system_messages else None
|
||||
return contents, system_instruction
|
||||
|
||||
@staticmethod
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if tools and "function_declarations" in tools[0]:
|
||||
return tools
|
||||
return [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"name": tool["name"],
|
||||
"description": tool["description"],
|
||||
"parameters": GeminiBackend._sanitize_schema(
|
||||
tool["input_schema"]
|
||||
),
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# JSON-Schema keywords Gemini's function_declarations validator accepts.
|
||||
# See https://ai.google.dev/api/caching#Schema. Anything outside this set
|
||||
# (e.g. additionalProperties, allOf, if/then/else, $ref, anyOf, oneOf,
|
||||
# patternProperties) triggers an INVALID_ARGUMENT 400 at call time, so we
|
||||
# strip on the way out. Other backends keep the richer schema.
|
||||
_GEMINI_ALLOWED_SCHEMA_KEYS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"type",
|
||||
"format",
|
||||
"description",
|
||||
"nullable",
|
||||
"enum",
|
||||
"properties",
|
||||
"required",
|
||||
"items",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"title",
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_schema(schema: Any) -> Any:
|
||||
"""Recursively strip JSON-Schema keywords Gemini rejects.
|
||||
|
||||
``properties`` holds user-supplied field names → sub-schemas, so we
|
||||
recurse into its values but preserve its keys. ``required`` and
|
||||
``enum`` are lists of literals (field names / allowed values) and are
|
||||
passed through verbatim. Everything else is a scalar schema keyword.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
schema_dict = cast(dict[str, Any], schema)
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in schema_dict.items():
|
||||
if key not in GeminiBackend._GEMINI_ALLOWED_SCHEMA_KEYS:
|
||||
continue
|
||||
if key == "properties" and isinstance(value, dict):
|
||||
cleaned["properties"] = {
|
||||
prop_name: GeminiBackend._sanitize_schema(prop_schema)
|
||||
for prop_name, prop_schema in cast(dict[str, Any], value).items()
|
||||
}
|
||||
elif key == "items":
|
||||
cleaned["items"] = GeminiBackend._sanitize_schema(value)
|
||||
elif key == "required" and isinstance(value, list):
|
||||
cleaned["required"] = list(cast(list[Any], value))
|
||||
elif key == "enum" and isinstance(value, list):
|
||||
cleaned["enum"] = list(cast(list[Any], value))
|
||||
else:
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _convert_tool_choice(
|
||||
tool_choice: str | dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(tool_choice, dict) and "name" in tool_choice:
|
||||
return {
|
||||
"function_calling_config": {
|
||||
"mode": "ANY",
|
||||
"allowed_function_names": [tool_choice["name"]],
|
||||
}
|
||||
}
|
||||
if tool_choice == "auto":
|
||||
return {"function_calling_config": {"mode": "AUTO"}}
|
||||
if tool_choice in {"any", "required"}:
|
||||
return {"function_calling_config": {"mode": "ANY"}}
|
||||
if tool_choice == "none":
|
||||
return {"function_calling_config": {"mode": "NONE"}}
|
||||
return {
|
||||
"function_calling_config": {
|
||||
"mode": "ANY",
|
||||
"allowed_function_names": [tool_choice],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, cast
|
||||
|
||||
from openai import BadRequestError, LengthFinishReasonError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.exceptions import ValidationException
|
||||
from src.llm.backend import CompletionResult, StreamChunk, ToolCallResult
|
||||
from src.llm.structured_output import (
|
||||
repair_response_model_json,
|
||||
validate_structured_output,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _uses_max_completion_tokens(model: str) -> bool:
|
||||
"""OpenAI reasoning models (gpt-5 family + o-series) require
|
||||
``max_completion_tokens`` instead of the classic ``max_tokens`` parameter.
|
||||
|
||||
Matches: gpt-5, gpt-5-anything, gpt-5.anything (incl. gpt-5.4, gpt-5.4-mini),
|
||||
o1*, o3*, o4*. Anything else (gpt-4.x, gpt-4o, chat models on proxies)
|
||||
stays on ``max_tokens``.
|
||||
"""
|
||||
m = model.lower()
|
||||
if m == "gpt-5" or m.startswith("gpt-5-") or m.startswith("gpt-5."):
|
||||
return True
|
||||
for prefix in ("o1", "o3", "o4"):
|
||||
if m == prefix or m.startswith(prefix + "-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_openai_reasoning_content(response: Any) -> str | None:
|
||||
try:
|
||||
message = response.choices[0].message
|
||||
if hasattr(message, "reasoning_details") and message.reasoning_details:
|
||||
reasoning_parts: list[str] = []
|
||||
for detail in message.reasoning_details:
|
||||
detail_content = getattr(detail, "content", None)
|
||||
if isinstance(detail_content, str) and detail_content:
|
||||
reasoning_parts.append(detail_content)
|
||||
elif isinstance(detail, dict):
|
||||
detail_dict = cast(dict[str, Any], detail)
|
||||
dict_content = detail_dict.get("content")
|
||||
if isinstance(dict_content, str) and dict_content:
|
||||
reasoning_parts.append(dict_content)
|
||||
if reasoning_parts:
|
||||
return "\n".join(reasoning_parts)
|
||||
if hasattr(message, "reasoning_content") and message.reasoning_content:
|
||||
return message.reasoning_content
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def extract_openai_reasoning_details(response: Any) -> list[dict[str, Any]]:
|
||||
try:
|
||||
message = response.choices[0].message
|
||||
if hasattr(message, "reasoning_details") and message.reasoning_details:
|
||||
details: list[dict[str, Any]] = []
|
||||
for detail in message.reasoning_details:
|
||||
if hasattr(detail, "model_dump"):
|
||||
dumped = detail.model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
details.append(cast(dict[str, Any], dumped))
|
||||
elif isinstance(detail, dict):
|
||||
details.append(cast(dict[str, Any], detail))
|
||||
else:
|
||||
detail_content = getattr(detail, "content", None)
|
||||
if isinstance(detail_content, str) and detail_content:
|
||||
details.append({"content": detail_content})
|
||||
return details
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def extract_openai_cache_tokens(usage: Any) -> tuple[int, int]:
|
||||
if not usage:
|
||||
return 0, 0
|
||||
|
||||
cache_read = 0
|
||||
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
|
||||
details = usage.prompt_tokens_details
|
||||
if hasattr(details, "cached_tokens") and details.cached_tokens:
|
||||
cache_read = details.cached_tokens
|
||||
|
||||
if cache_read == 0:
|
||||
if hasattr(usage, "cache_read_input_tokens") and usage.cache_read_input_tokens:
|
||||
cache_read = usage.cache_read_input_tokens
|
||||
elif hasattr(usage, "cached_tokens") and usage.cached_tokens:
|
||||
cache_read = usage.cached_tokens
|
||||
|
||||
cache_creation = 0
|
||||
if (
|
||||
hasattr(usage, "cache_creation_input_tokens")
|
||||
and usage.cache_creation_input_tokens
|
||||
):
|
||||
cache_creation = usage.cache_creation_input_tokens
|
||||
|
||||
return cache_creation, cache_read
|
||||
|
||||
|
||||
class OpenAIBackend:
|
||||
"""Provider backend wrapping AsyncOpenAI."""
|
||||
|
||||
def __init__(self, client: Any) -> None:
|
||||
self._client: Any = client
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> CompletionResult:
|
||||
if thinking_budget_tokens is not None:
|
||||
raise ValidationException(
|
||||
"OpenAI backend does not support thinking_budget_tokens; use thinking_effort instead"
|
||||
)
|
||||
|
||||
params = self._build_params(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_output_tokens or max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
thinking_effort=thinking_effort,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
if isinstance(response_format, type):
|
||||
params["response_format"] = response_format
|
||||
try:
|
||||
response = await self._client.chat.completions.parse(**params)
|
||||
except LengthFinishReasonError as exc:
|
||||
truncated = exc.completion
|
||||
raw_content = truncated.choices[0].message.content or ""
|
||||
content = repair_response_model_json(
|
||||
raw_content,
|
||||
response_format,
|
||||
model,
|
||||
)
|
||||
return self._normalize_response(
|
||||
truncated,
|
||||
content_override=content,
|
||||
)
|
||||
except (BadRequestError, json.JSONDecodeError, ValidationError):
|
||||
fallback_response = await self._create_structured_response(
|
||||
params=params,
|
||||
response_format=response_format,
|
||||
)
|
||||
content = self._parse_or_repair_structured_content(
|
||||
fallback_response,
|
||||
response_format,
|
||||
model,
|
||||
)
|
||||
return self._normalize_response(
|
||||
fallback_response,
|
||||
content_override=content,
|
||||
)
|
||||
parsed = response.choices[0].message.parsed
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
if parsed is None and raw_content:
|
||||
content = repair_response_model_json(
|
||||
raw_content,
|
||||
response_format,
|
||||
model,
|
||||
)
|
||||
return self._normalize_response(response, content_override=content)
|
||||
if parsed is None:
|
||||
refusal = getattr(response.choices[0].message, "refusal", None)
|
||||
if refusal:
|
||||
return self._normalize_response(
|
||||
response,
|
||||
content_override=refusal,
|
||||
)
|
||||
raise ValidationException("No parsed content in structured response")
|
||||
return self._normalize_response(
|
||||
response,
|
||||
content_override=validate_structured_output(parsed, response_format),
|
||||
)
|
||||
if response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
|
||||
if extra_params and extra_params.get("json_mode"):
|
||||
params["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = await self._client.chat.completions.create(**params)
|
||||
return self._normalize_response(response)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
stop: list[str] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
thinking_effort: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
if thinking_budget_tokens is not None:
|
||||
raise ValidationException(
|
||||
"OpenAI backend does not support thinking_budget_tokens; use thinking_effort instead"
|
||||
)
|
||||
|
||||
params = self._build_params(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_output_tokens or max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
thinking_effort=thinking_effort,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
params["stream"] = True
|
||||
params["stream_options"] = {"include_usage": True}
|
||||
if isinstance(response_format, type):
|
||||
# parse() supports BaseModel types but streaming create() does not —
|
||||
# convert to a json_schema dict so the streaming path works.
|
||||
params["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
elif response_format is not None:
|
||||
params["response_format"] = response_format
|
||||
elif extra_params and extra_params.get("json_mode"):
|
||||
params["response_format"] = {"type": "json_object"}
|
||||
|
||||
response_stream = await self._client.chat.completions.create(**params)
|
||||
finish_reason: str | None = None
|
||||
usage_chunk_received = False
|
||||
async for chunk in response_stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield StreamChunk(content=chunk.choices[0].delta.content)
|
||||
if chunk.choices and chunk.choices[0].finish_reason:
|
||||
finish_reason = chunk.choices[0].finish_reason
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
yield StreamChunk(
|
||||
is_done=True,
|
||||
finish_reason=finish_reason,
|
||||
output_tokens=chunk.usage.completion_tokens,
|
||||
)
|
||||
usage_chunk_received = True
|
||||
|
||||
if not usage_chunk_received and finish_reason:
|
||||
yield StreamChunk(is_done=True, finish_reason=finish_reason)
|
||||
|
||||
def _build_params(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
temperature: float | None,
|
||||
stop: list[str] | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
thinking_effort: str | None,
|
||||
extra_params: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if _uses_max_completion_tokens(model):
|
||||
params["max_completion_tokens"] = max_tokens
|
||||
if extra_params and extra_params.get("verbosity"):
|
||||
params["verbosity"] = extra_params["verbosity"]
|
||||
else:
|
||||
params["max_tokens"] = max_tokens
|
||||
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
if thinking_effort:
|
||||
params["reasoning_effort"] = thinking_effort
|
||||
|
||||
if stop:
|
||||
params["stop"] = stop
|
||||
if tools:
|
||||
params["tools"] = self._convert_tools(tools)
|
||||
if tool_choice is not None:
|
||||
params["tool_choice"] = tool_choice
|
||||
if extra_params:
|
||||
for key in (
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"seed",
|
||||
):
|
||||
if key in extra_params:
|
||||
params[key] = extra_params[key]
|
||||
return params
|
||||
|
||||
def _normalize_response(
|
||||
self,
|
||||
response: Any,
|
||||
*,
|
||||
content_override: Any | None = None,
|
||||
) -> CompletionResult:
|
||||
usage = response.usage
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
tool_calls: list[ToolCallResult] = []
|
||||
message = response.choices[0].message
|
||||
if getattr(message, "tool_calls", None):
|
||||
for tool_call in message.tool_calls:
|
||||
tool_input: dict[str, Any] = {}
|
||||
if tool_call.function.arguments:
|
||||
try:
|
||||
tool_input = json.loads(tool_call.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
# Don't log the raw arguments payload — LLM-generated
|
||||
# tool calls can mirror user PII from the prompt into
|
||||
# their arguments, and this runs at WARN level.
|
||||
logger.warning(
|
||||
"Malformed tool arguments for %s (id=%s): %s",
|
||||
tool_call.function.name,
|
||||
tool_call.id,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCallResult(
|
||||
id=tool_call.id,
|
||||
name=tool_call.function.name,
|
||||
input=tool_input,
|
||||
)
|
||||
)
|
||||
|
||||
cache_creation, cache_read = extract_openai_cache_tokens(usage)
|
||||
return CompletionResult(
|
||||
content=content_override
|
||||
if content_override is not None
|
||||
else (message.content or ""),
|
||||
input_tokens=usage.prompt_tokens if usage else 0,
|
||||
output_tokens=usage.completion_tokens if usage else 0,
|
||||
cache_creation_input_tokens=cache_creation,
|
||||
cache_read_input_tokens=cache_read,
|
||||
finish_reason=finish_reason or "stop",
|
||||
tool_calls=tool_calls,
|
||||
thinking_content=extract_openai_reasoning_content(response),
|
||||
reasoning_details=extract_openai_reasoning_details(response),
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
async def _create_structured_response(
|
||||
self,
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
response_format: type[BaseModel],
|
||||
) -> Any:
|
||||
structured_params = dict(params)
|
||||
structured_params["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"schema": response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
return await self._client.chat.completions.create(**structured_params)
|
||||
|
||||
@staticmethod
|
||||
def _parse_or_repair_structured_content(
|
||||
response: Any,
|
||||
response_format: type[BaseModel],
|
||||
model: str,
|
||||
) -> BaseModel | str:
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
if raw_content:
|
||||
return repair_response_model_json(raw_content, response_format, model)
|
||||
refusal = getattr(response.choices[0].message, "refusal", None)
|
||||
if refusal:
|
||||
return refusal
|
||||
raise ValidationException(
|
||||
"No raw content available for structured output repair"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not tools or tools[0].get("type") == "function":
|
||||
return tools
|
||||
# Tool schemas in src/utils/agent_tools.py use optional fields with
|
||||
# defaults and don't declare additionalProperties: false. OpenAI's
|
||||
# strict function-calling mode forbids both, so we intentionally
|
||||
# don't set strict: True. Standard function calling on GPT-4.x /
|
||||
# GPT-5 remains reliable, and this stays compatible with
|
||||
# OpenAI-compatible proxies (OpenRouter, Together, vLLM, Ollama)
|
||||
# whose strict-mode support is inconsistent.
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool["name"],
|
||||
"description": tool["description"],
|
||||
"parameters": tool["input_schema"],
|
||||
},
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import ModelConfig, PromptCachePolicy
|
||||
|
||||
__all__ = [
|
||||
"GeminiCacheHandle",
|
||||
"InMemoryGeminiCacheStore",
|
||||
"PromptCachePolicy",
|
||||
"build_cache_key",
|
||||
"gemini_cache_store",
|
||||
]
|
||||
|
||||
|
||||
class GeminiCacheHandle(BaseModel):
|
||||
key: str
|
||||
cached_content_name: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
*,
|
||||
config: ModelConfig,
|
||||
cache_policy: PromptCachePolicy,
|
||||
cacheable_messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
system_instruction: str | None = None,
|
||||
tool_config: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Deterministic key over the cacheable shape of a request.
|
||||
|
||||
``system_instruction`` and ``tool_config`` must be part of the key
|
||||
because the provider's cached-content handle captures them at creation
|
||||
time — two requests that differ only by system prompt or tool
|
||||
constraints would otherwise hit the same cached handle and silently get
|
||||
the wrong system prompt / tool policy.
|
||||
"""
|
||||
payload = {
|
||||
"transport": config.transport,
|
||||
"model": config.model,
|
||||
"cache_policy": cache_policy.model_dump(mode="json"),
|
||||
"messages": cacheable_messages,
|
||||
"tools": tools,
|
||||
"system_instruction": system_instruction,
|
||||
"tool_config": tool_config,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
return f"llm-cache:{cache_policy.key_version}:{digest}"
|
||||
|
||||
|
||||
class InMemoryGeminiCacheStore:
|
||||
"""Best-effort local cache for Gemini cached-content handles.
|
||||
|
||||
Uses LRU eviction with a max entry limit to prevent unbounded growth.
|
||||
"""
|
||||
|
||||
MAX_ENTRIES: int = 1024
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handles: OrderedDict[str, GeminiCacheHandle] = OrderedDict()
|
||||
self._lock: Lock = Lock()
|
||||
|
||||
def get(self, key: str) -> GeminiCacheHandle | None:
|
||||
with self._lock:
|
||||
handle = self._handles.get(key)
|
||||
if handle is None:
|
||||
return None
|
||||
if handle.expires_at <= datetime.now(timezone.utc):
|
||||
self._handles.pop(key, None)
|
||||
return None
|
||||
self._handles.move_to_end(key)
|
||||
return handle
|
||||
|
||||
def set(self, handle: GeminiCacheHandle) -> GeminiCacheHandle:
|
||||
with self._lock:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [k for k, h in self._handles.items() if h.expires_at <= now]
|
||||
for k in expired:
|
||||
self._handles.pop(k, None)
|
||||
if handle.key in self._handles:
|
||||
self._handles.move_to_end(handle.key)
|
||||
self._handles[handle.key] = handle
|
||||
while len(self._handles) > self.MAX_ENTRIES:
|
||||
self._handles.popitem(last=False)
|
||||
return handle
|
||||
|
||||
|
||||
gemini_cache_store = InMemoryGeminiCacheStore()
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"""Conversation-shaping helpers: token counting + tool-aware truncation.
|
||||
|
||||
Moved out of src/utils/clients.py as part of the migration into src/llm/.
|
||||
These are pure helpers with no orchestration dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from src.utils.tokens import estimate_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def count_message_tokens(messages: list[dict[str, Any]]) -> int:
|
||||
"""Count tokens in a list of messages using tiktoken."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
total += estimate_tokens(content)
|
||||
elif isinstance(content, list):
|
||||
# Anthropic-style content blocks
|
||||
total += estimate_tokens(json.dumps(content))
|
||||
if "parts" in msg:
|
||||
try:
|
||||
total += estimate_tokens(json.dumps(msg["parts"]))
|
||||
except TypeError:
|
||||
# Non-JSON-serializable content (e.g. bytes) — estimate from repr.
|
||||
total += estimate_tokens(str(msg["parts"]))
|
||||
return total
|
||||
|
||||
|
||||
def _is_tool_use_message(msg: dict[str, Any]) -> bool:
|
||||
"""Check if a message contains tool calls (any format).
|
||||
|
||||
Recognizes:
|
||||
- Anthropic: ``content`` is a list containing a ``{"type": "tool_use"}`` block.
|
||||
- Gemini: ``parts`` is a list containing a ``{"function_call": …}`` entry.
|
||||
- OpenAI: assistant message with a non-empty ``tool_calls`` field.
|
||||
"""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in cast(list[dict[str, Any]], content):
|
||||
if block.get("type") == "tool_use":
|
||||
return True
|
||||
parts = msg.get("parts")
|
||||
if isinstance(parts, list):
|
||||
for part in cast(list[dict[str, Any]], parts):
|
||||
if "function_call" in part:
|
||||
return True
|
||||
return bool(msg.get("tool_calls"))
|
||||
|
||||
|
||||
def _is_tool_result_message(msg: dict[str, Any]) -> bool:
|
||||
"""Check if a message contains tool results (any format).
|
||||
|
||||
Recognizes:
|
||||
- Anthropic: ``content`` is a list containing a ``{"type": "tool_result"}`` block.
|
||||
- Gemini: ``parts`` is a list containing a ``{"function_response": …}`` entry.
|
||||
- OpenAI: message with ``role == "tool"``.
|
||||
"""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in cast(list[dict[str, Any]], content):
|
||||
if block.get("type") == "tool_result":
|
||||
return True
|
||||
parts = msg.get("parts")
|
||||
if isinstance(parts, list):
|
||||
for part in cast(list[dict[str, Any]], parts):
|
||||
if "function_response" in part:
|
||||
return True
|
||||
return msg.get("role") == "tool"
|
||||
|
||||
|
||||
def _group_into_units(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""Group messages into logical conversation units.
|
||||
|
||||
A unit is either:
|
||||
- A tool_use message + ALL consecutive tool_result messages that follow
|
||||
- A single non-tool message
|
||||
|
||||
Keeps tool_use / tool_result pairs together so truncation never breaks
|
||||
them apart.
|
||||
"""
|
||||
units: list[list[dict[str, Any]]] = []
|
||||
i = 0
|
||||
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
|
||||
if _is_tool_use_message(msg):
|
||||
j = i + 1
|
||||
while j < len(messages) and _is_tool_result_message(messages[j]):
|
||||
j += 1
|
||||
unit = messages[i:j]
|
||||
if len(unit) > 1:
|
||||
units.append(unit)
|
||||
i = j
|
||||
else:
|
||||
# Orphaned tool_use with no results — skip it.
|
||||
logger.debug(f"Skipping orphaned tool_use at index {i}")
|
||||
i += 1
|
||||
elif _is_tool_result_message(msg):
|
||||
# Orphaned tool_result — skip it.
|
||||
logger.debug(f"Skipping orphaned tool_result at index {i}")
|
||||
i += 1
|
||||
else:
|
||||
units.append([msg])
|
||||
i += 1
|
||||
|
||||
return units
|
||||
|
||||
|
||||
def truncate_messages_to_fit(
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
preserve_system: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Truncate messages to fit within a token limit while maintaining valid structure.
|
||||
|
||||
Strategy:
|
||||
1. Group messages into units (tool_use + results together, or single messages)
|
||||
2. Remove oldest units first to preserve recent context
|
||||
3. Units stay intact so tool_use/tool_result pairs are never broken
|
||||
"""
|
||||
current_tokens = count_message_tokens(messages)
|
||||
if current_tokens <= max_tokens:
|
||||
return messages
|
||||
|
||||
logger.info(f"Truncating: {current_tokens} tokens exceeds {max_tokens} limit")
|
||||
|
||||
system_messages: list[dict[str, Any]] = []
|
||||
conversation: list[dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system" and preserve_system:
|
||||
system_messages.append(msg)
|
||||
else:
|
||||
conversation.append(msg)
|
||||
|
||||
system_tokens = count_message_tokens(system_messages)
|
||||
available_tokens = max_tokens - system_tokens
|
||||
|
||||
if available_tokens <= 0:
|
||||
logger.warning("System message exceeds max_input_tokens")
|
||||
return messages
|
||||
|
||||
units = _group_into_units(conversation)
|
||||
|
||||
if not units:
|
||||
logger.warning("No valid conversation units")
|
||||
return system_messages
|
||||
|
||||
# Drop oldest units until conversation fits, but keep at least one unit so
|
||||
# we never erase the entire non-system conversation.
|
||||
while len(units) > 1:
|
||||
flat_messages = [m for unit in units for m in unit]
|
||||
if count_message_tokens(flat_messages) <= available_tokens:
|
||||
break
|
||||
removed_unit = units.pop(0)
|
||||
logger.debug(
|
||||
"Dropping conversation unit with "
|
||||
+ f"{len(removed_unit)} messages "
|
||||
+ f"(~{count_message_tokens(removed_unit)} tokens)"
|
||||
)
|
||||
|
||||
result = system_messages + [m for unit in units for m in unit]
|
||||
result_tokens = count_message_tokens(result)
|
||||
logger.info(
|
||||
f"Truncation complete: {current_tokens} → {result_tokens} tokens "
|
||||
+ f"({len(messages)} → {len(result)} messages)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"count_message_tokens",
|
||||
"truncate_messages_to_fit",
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from src.config import ModelConfig, settings
|
||||
from src.exceptions import ValidationException
|
||||
|
||||
|
||||
def resolve_credentials(config: ModelConfig) -> dict[str, str | None]:
|
||||
"""Resolve credentials for the effective model transport."""
|
||||
|
||||
default_api_key = default_transport_api_key(config.transport)
|
||||
return {
|
||||
"api_key": config.api_key or default_api_key,
|
||||
"api_base": config.base_url,
|
||||
}
|
||||
|
||||
|
||||
def default_transport_api_key(transport: str) -> str | None:
|
||||
"""Fall back to the global LLM API key for the matching transport."""
|
||||
if transport == "anthropic":
|
||||
return settings.LLM.ANTHROPIC_API_KEY
|
||||
if transport == "openai":
|
||||
return settings.LLM.OPENAI_API_KEY
|
||||
if transport == "gemini":
|
||||
return settings.LLM.GEMINI_API_KEY
|
||||
raise ValidationException(f"Unknown transport: {transport}")
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
"""Single-call executor: the inner LLM-call path without tool-loop orchestration.
|
||||
|
||||
`honcho_llm_call_inner` handles one backend call (complete or stream), building
|
||||
the effective ModelConfig and delegating to request_builder. Result / stream
|
||||
chunk types are bridged to the public Honcho* shapes here.
|
||||
|
||||
Used by:
|
||||
- src/llm/api.py (the public entrypoint, for both tool-less and tool-enabled paths)
|
||||
- src/llm/tool_loop.py (each iteration of the tool loop calls this)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Literal, TypeVar, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import ModelConfig, ModelTransport
|
||||
|
||||
from .backend import CompletionResult as BackendCompletionResult
|
||||
from .backend import StreamChunk as BackendStreamChunk
|
||||
from .backend import ToolCallResult
|
||||
from .registry import CLIENTS, backend_for_provider
|
||||
from .request_builder import execute_completion, execute_stream
|
||||
from .runtime import effective_config_for_call
|
||||
from .types import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
ProviderClient,
|
||||
ReasoningEffortType,
|
||||
)
|
||||
|
||||
M = TypeVar("M", bound=BaseModel)
|
||||
|
||||
|
||||
def _tool_call_result_to_dict(tool_call: ToolCallResult) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"input": tool_call.input,
|
||||
}
|
||||
if tool_call.thought_signature is not None:
|
||||
result["thought_signature"] = tool_call.thought_signature
|
||||
return result
|
||||
|
||||
|
||||
def completion_result_to_response(
|
||||
result: BackendCompletionResult,
|
||||
) -> HonchoLLMCallResponse[Any]:
|
||||
return HonchoLLMCallResponse(
|
||||
content=result.content,
|
||||
input_tokens=result.input_tokens,
|
||||
output_tokens=result.output_tokens,
|
||||
cache_creation_input_tokens=result.cache_creation_input_tokens,
|
||||
cache_read_input_tokens=result.cache_read_input_tokens,
|
||||
finish_reasons=[result.finish_reason] if result.finish_reason else [],
|
||||
tool_calls_made=[_tool_call_result_to_dict(tc) for tc in result.tool_calls],
|
||||
thinking_content=result.thinking_content,
|
||||
thinking_blocks=result.thinking_blocks,
|
||||
reasoning_details=result.reasoning_details,
|
||||
)
|
||||
|
||||
|
||||
def stream_chunk_to_response_chunk(
|
||||
chunk: BackendStreamChunk,
|
||||
) -> HonchoLLMCallStreamChunk:
|
||||
return HonchoLLMCallStreamChunk(
|
||||
content=chunk.content,
|
||||
is_done=chunk.is_done,
|
||||
finish_reasons=[chunk.finish_reason] if chunk.finish_reason else [],
|
||||
output_tokens=chunk.output_tokens,
|
||||
)
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call_inner(
|
||||
provider: ModelTransport,
|
||||
model: str,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
response_model: type[M],
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
stream: Literal[False] = False,
|
||||
client_override: ProviderClient | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
selected_config: ModelConfig | None = None,
|
||||
) -> HonchoLLMCallResponse[M]: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call_inner(
|
||||
provider: ModelTransport,
|
||||
model: str,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
response_model: None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
stream: Literal[False] = False,
|
||||
client_override: ProviderClient | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
selected_config: ModelConfig | None = None,
|
||||
) -> HonchoLLMCallResponse[str]: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def honcho_llm_call_inner(
|
||||
provider: ModelTransport,
|
||||
model: str,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
stream: Literal[True] = ...,
|
||||
client_override: ProviderClient | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
selected_config: ModelConfig | None = None,
|
||||
) -> AsyncIterator[HonchoLLMCallStreamChunk]: ...
|
||||
|
||||
|
||||
async def honcho_llm_call_inner(
|
||||
provider: ModelTransport,
|
||||
model: str,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
response_model: type[BaseModel] | None = None,
|
||||
json_mode: bool = False,
|
||||
temperature: float | None = None,
|
||||
stop_seqs: list[str] | None = None,
|
||||
reasoning_effort: ReasoningEffortType = None,
|
||||
verbosity: Literal["low", "medium", "high"] | None = None,
|
||||
thinking_budget_tokens: int | None = None,
|
||||
stream: bool = False,
|
||||
client_override: ProviderClient | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
selected_config: ModelConfig | None = None,
|
||||
) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]:
|
||||
"""One backend call. No retry, no fallback, no tool loop.
|
||||
|
||||
The outer src/llm/api.py `honcho_llm_call` handles retry + fallback +
|
||||
tool orchestration on top of this.
|
||||
"""
|
||||
client = client_override or CLIENTS.get(provider)
|
||||
if client is None:
|
||||
raise ValueError(f"Missing client for {provider}")
|
||||
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
backend = backend_for_provider(provider, client)
|
||||
|
||||
effective_config = effective_config_for_call(
|
||||
selected_config=selected_config,
|
||||
provider=provider,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
thinking_budget_tokens=thinking_budget_tokens,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
# json_mode + verbosity are per-call transport toggles, not ModelConfig
|
||||
# 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 stream:
|
||||
|
||||
async def _stream() -> AsyncIterator[HonchoLLMCallStreamChunk]:
|
||||
stream_iter = await execute_stream(
|
||||
backend,
|
||||
effective_config,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_model,
|
||||
cache_policy=effective_config.cache_policy,
|
||||
extra_params=call_extras,
|
||||
)
|
||||
async for chunk in stream_iter:
|
||||
yield stream_chunk_to_response_chunk(chunk)
|
||||
|
||||
return _stream()
|
||||
|
||||
result = await execute_completion(
|
||||
backend,
|
||||
effective_config,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_model,
|
||||
cache_policy=effective_config.cache_policy,
|
||||
extra_params=call_extras,
|
||||
)
|
||||
return completion_result_to_response(result)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"completion_result_to_response",
|
||||
"honcho_llm_call_inner",
|
||||
"stream_chunk_to_response_chunk",
|
||||
]
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .backend import CompletionResult
|
||||
|
||||
|
||||
class HistoryAdapter(Protocol):
|
||||
def format_assistant_tool_message(
|
||||
self,
|
||||
result: CompletionResult,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def format_tool_results(
|
||||
self,
|
||||
tool_results: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class AnthropicHistoryAdapter:
|
||||
def format_assistant_tool_message(
|
||||
self,
|
||||
result: CompletionResult,
|
||||
) -> dict[str, Any]:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if result.thinking_blocks:
|
||||
content_blocks.extend(result.thinking_blocks)
|
||||
if isinstance(result.content, str) and result.content:
|
||||
content_blocks.append({"type": "text", "text": result.content})
|
||||
for tool_call in result.tool_calls:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"input": tool_call.input,
|
||||
}
|
||||
)
|
||||
return {"role": "assistant", "content": content_blocks}
|
||||
|
||||
def format_tool_results(
|
||||
self,
|
||||
tool_results: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tr["tool_id"],
|
||||
"content": str(tr["result"]),
|
||||
"is_error": tr.get("is_error", False),
|
||||
}
|
||||
for tr in tool_results
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class GeminiHistoryAdapter:
|
||||
def format_assistant_tool_message(
|
||||
self,
|
||||
result: CompletionResult,
|
||||
) -> dict[str, Any]:
|
||||
parts: list[dict[str, Any]] = []
|
||||
if isinstance(result.content, str) and result.content:
|
||||
parts.append({"text": result.content})
|
||||
for tool_call in result.tool_calls:
|
||||
part: dict[str, Any] = {
|
||||
"function_call": {
|
||||
"name": tool_call.name,
|
||||
"args": tool_call.input,
|
||||
}
|
||||
}
|
||||
if tool_call.thought_signature is not None:
|
||||
part["thought_signature"] = tool_call.thought_signature
|
||||
parts.append(part)
|
||||
return {"role": "model", "parts": parts}
|
||||
|
||||
def format_tool_results(
|
||||
self,
|
||||
tool_results: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"function_response": {
|
||||
"name": tr["tool_name"],
|
||||
"response": {"result": str(tr["result"])},
|
||||
}
|
||||
}
|
||||
for tr in tool_results
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class OpenAIHistoryAdapter:
|
||||
def format_assistant_tool_message(
|
||||
self,
|
||||
result: CompletionResult,
|
||||
) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": result.content if isinstance(result.content, str) else None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.name,
|
||||
"arguments": json.dumps(tool_call.input),
|
||||
},
|
||||
}
|
||||
for tool_call in result.tool_calls
|
||||
],
|
||||
}
|
||||
if result.reasoning_details:
|
||||
message["reasoning_details"] = result.reasoning_details
|
||||
return message
|
||||
|
||||
def format_tool_results(
|
||||
self,
|
||||
tool_results: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tr["tool_id"],
|
||||
"content": str(tr["result"]),
|
||||
}
|
||||
for tr in tool_results
|
||||
]
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"""Single owner of provider runtime objects: clients, backends, history adapters.
|
||||
|
||||
Consolidates wiring that previously lived in both `src/llm/__init__.py` and
|
||||
`src/utils/clients.py`. Everything that touches provider SDKs at runtime
|
||||
(default client construction, override client caching, backend selection,
|
||||
history adapter selection) lives here now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import assert_never
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from src.config import ModelConfig, ModelTransport, settings
|
||||
from src.exceptions import ValidationException
|
||||
|
||||
from .backend import ProviderBackend
|
||||
from .backends.anthropic import AnthropicBackend
|
||||
from .backends.gemini import GeminiBackend
|
||||
from .backends.openai import OpenAIBackend
|
||||
from .credentials import default_transport_api_key
|
||||
from .history_adapters import (
|
||||
AnthropicHistoryAdapter,
|
||||
GeminiHistoryAdapter,
|
||||
HistoryAdapter,
|
||||
OpenAIHistoryAdapter,
|
||||
)
|
||||
from .types import ProviderClient
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_anthropic_client() -> AsyncAnthropic:
|
||||
"""Default Anthropic client built from settings.LLM.ANTHROPIC_API_KEY."""
|
||||
return AsyncAnthropic(
|
||||
api_key=settings.LLM.ANTHROPIC_API_KEY,
|
||||
timeout=600.0,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_openai_client() -> AsyncOpenAI:
|
||||
"""Default OpenAI client built from settings.LLM.OPENAI_API_KEY."""
|
||||
return AsyncOpenAI(
|
||||
api_key=settings.LLM.OPENAI_API_KEY,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_gemini_client() -> genai.Client:
|
||||
"""Default Gemini client built from settings.LLM.GEMINI_API_KEY."""
|
||||
return genai.Client(api_key=settings.LLM.GEMINI_API_KEY)
|
||||
|
||||
|
||||
# Bounded cache — in practice the (base_url, api_key) key space is small
|
||||
# and process-scoped, but maxsize=128 keeps worst-case memory predictable.
|
||||
@lru_cache(maxsize=128)
|
||||
def get_openai_override_client(
|
||||
base_url: str | None, api_key: str | None
|
||||
) -> AsyncOpenAI:
|
||||
"""OpenAI client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
return AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_anthropic_override_client(
|
||||
base_url: str | None,
|
||||
api_key: str | None,
|
||||
) -> AsyncAnthropic:
|
||||
"""Anthropic client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
return AsyncAnthropic(api_key=api_key, base_url=base_url, timeout=600.0)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_gemini_override_client(
|
||||
base_url: str | None, api_key: str | None
|
||||
) -> genai.Client:
|
||||
"""Gemini client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
http_options = genai_types.HttpOptions(base_url=base_url) if base_url else None
|
||||
return genai.Client(api_key=api_key, http_options=http_options)
|
||||
|
||||
|
||||
# Module-level default-client registry, populated at import time. Tests patch
|
||||
# this dict via `patch.dict(CLIENTS, {...})` to inject mock provider clients.
|
||||
CLIENTS: dict[ModelTransport, ProviderClient] = {}
|
||||
|
||||
if settings.LLM.ANTHROPIC_API_KEY:
|
||||
CLIENTS["anthropic"] = AsyncAnthropic(
|
||||
api_key=settings.LLM.ANTHROPIC_API_KEY,
|
||||
timeout=600.0,
|
||||
)
|
||||
|
||||
if settings.LLM.OPENAI_API_KEY:
|
||||
CLIENTS["openai"] = AsyncOpenAI(
|
||||
api_key=settings.LLM.OPENAI_API_KEY,
|
||||
)
|
||||
|
||||
if settings.LLM.GEMINI_API_KEY:
|
||||
CLIENTS["gemini"] = genai.client.Client(
|
||||
api_key=settings.LLM.GEMINI_API_KEY,
|
||||
)
|
||||
|
||||
|
||||
def client_for_model_config(
|
||||
provider: ModelTransport,
|
||||
model_config: ModelConfig,
|
||||
) -> ProviderClient:
|
||||
"""Resolve the provider client for a ModelConfig.
|
||||
|
||||
Fast path: no overrides → reuse the module-level default client from
|
||||
CLIENTS (the test-mockable seam). Otherwise route through the cached
|
||||
override factories.
|
||||
"""
|
||||
if model_config.api_key is None and model_config.base_url is None:
|
||||
existing_client = CLIENTS.get(provider)
|
||||
if existing_client is not None:
|
||||
return existing_client
|
||||
|
||||
api_key = model_config.api_key or default_transport_api_key(provider)
|
||||
base_url = model_config.base_url
|
||||
if not api_key:
|
||||
raise ValidationException(f"Missing API key for {provider} model config")
|
||||
|
||||
if provider == "anthropic":
|
||||
return get_anthropic_override_client(base_url, api_key)
|
||||
if provider == "openai":
|
||||
return get_openai_override_client(base_url, api_key)
|
||||
if provider == "gemini":
|
||||
return get_gemini_override_client(base_url, api_key)
|
||||
assert_never(provider)
|
||||
|
||||
|
||||
def backend_for_provider(
|
||||
provider: ModelTransport,
|
||||
client: ProviderClient,
|
||||
) -> ProviderBackend:
|
||||
"""Wrap a raw provider SDK client in the matching ProviderBackend adapter."""
|
||||
if provider == "anthropic":
|
||||
return AnthropicBackend(client)
|
||||
if provider == "openai":
|
||||
return OpenAIBackend(client)
|
||||
if provider == "gemini":
|
||||
return GeminiBackend(client)
|
||||
assert_never(provider)
|
||||
|
||||
|
||||
def history_adapter_for_provider(provider: ModelTransport) -> HistoryAdapter:
|
||||
"""Provider-appropriate HistoryAdapter for assistant/tool message formatting."""
|
||||
if provider == "anthropic":
|
||||
return AnthropicHistoryAdapter()
|
||||
if provider == "gemini":
|
||||
return GeminiHistoryAdapter()
|
||||
return OpenAIHistoryAdapter()
|
||||
|
||||
|
||||
def get_backend(config: ModelConfig) -> ProviderBackend:
|
||||
"""High-level one-shot backend factory: ModelConfig → ProviderBackend.
|
||||
|
||||
Delegates client resolution to ``client_for_model_config``, which owns
|
||||
the CLIENTS fast-path and the missing-API-key validation. Both the
|
||||
production path (via ``honcho_llm_call_inner``) and the live-test path
|
||||
(via this function) now construct clients through the same helper, so
|
||||
validation behavior stays consistent.
|
||||
"""
|
||||
client = client_for_model_config(config.transport, config)
|
||||
return backend_for_provider(config.transport, client)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLIENTS",
|
||||
"backend_for_provider",
|
||||
"client_for_model_config",
|
||||
"get_anthropic_client",
|
||||
"get_anthropic_override_client",
|
||||
"get_backend",
|
||||
"get_gemini_client",
|
||||
"get_gemini_override_client",
|
||||
"get_openai_client",
|
||||
"get_openai_override_client",
|
||||
"history_adapter_for_provider",
|
||||
]
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"""Low-level request assembly: flatten a ModelConfig into backend calls.
|
||||
|
||||
Does NOT own: retry, fallback, tool loop, provider selection. Those live in
|
||||
src/llm/api.py, src/llm/tool_loop.py, src/llm/runtime.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import ModelConfig, PromptCachePolicy
|
||||
|
||||
from .backend import CompletionResult, ProviderBackend, StreamChunk
|
||||
|
||||
|
||||
def build_config_extra_params(config: ModelConfig) -> dict[str, Any]:
|
||||
"""Flatten ModelConfig's optional knobs and provider_params into extra_params.
|
||||
|
||||
Backends read per-call tuning parameters (top_p, top_k, frequency_penalty,
|
||||
presence_penalty, seed) and the free-form provider_params passthrough out
|
||||
of ``extra_params``. Single source of truth for that translation.
|
||||
"""
|
||||
extra_params: dict[str, Any] = {}
|
||||
if config.top_p is not None:
|
||||
extra_params["top_p"] = config.top_p
|
||||
if config.top_k is not None:
|
||||
extra_params["top_k"] = config.top_k
|
||||
if config.frequency_penalty is not None:
|
||||
extra_params["frequency_penalty"] = config.frequency_penalty
|
||||
if config.presence_penalty is not None:
|
||||
extra_params["presence_penalty"] = config.presence_penalty
|
||||
if config.seed is not None:
|
||||
extra_params["seed"] = config.seed
|
||||
|
||||
if config.provider_params:
|
||||
extra_params.update(config.provider_params)
|
||||
|
||||
return extra_params
|
||||
|
||||
|
||||
async def execute_completion(
|
||||
backend: ProviderBackend,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
cache_policy: PromptCachePolicy | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> CompletionResult:
|
||||
# Preserve 0 as an explicit "disable thinking" value (used by Gemini);
|
||||
# only convert to None when the field is truly unset.
|
||||
effective_max_tokens = config.max_output_tokens or max_tokens
|
||||
|
||||
merged_extra_params = {
|
||||
**build_config_extra_params(config),
|
||||
**(extra_params or {}),
|
||||
}
|
||||
if cache_policy is not None:
|
||||
merged_extra_params["cache_policy"] = cache_policy
|
||||
|
||||
return await backend.complete(
|
||||
model=config.model,
|
||||
messages=messages,
|
||||
max_tokens=effective_max_tokens,
|
||||
temperature=config.temperature,
|
||||
stop=stop if stop is not None else config.stop_sequences,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_format,
|
||||
thinking_budget_tokens=config.thinking_budget_tokens,
|
||||
thinking_effort=config.thinking_effort,
|
||||
max_output_tokens=effective_max_tokens,
|
||||
extra_params=merged_extra_params,
|
||||
)
|
||||
|
||||
|
||||
async def execute_stream(
|
||||
backend: ProviderBackend,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
cache_policy: PromptCachePolicy | None = None,
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
effective_max_tokens = config.max_output_tokens or max_tokens
|
||||
|
||||
merged_extra_params = {
|
||||
**build_config_extra_params(config),
|
||||
**(extra_params or {}),
|
||||
}
|
||||
if cache_policy is not None:
|
||||
merged_extra_params["cache_policy"] = cache_policy
|
||||
|
||||
return backend.stream(
|
||||
model=config.model,
|
||||
messages=messages,
|
||||
max_tokens=effective_max_tokens,
|
||||
temperature=config.temperature,
|
||||
stop=stop if stop is not None else config.stop_sequences,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
response_format=response_format,
|
||||
thinking_budget_tokens=config.thinking_budget_tokens,
|
||||
thinking_effort=config.thinking_effort,
|
||||
max_output_tokens=effective_max_tokens,
|
||||
extra_params=merged_extra_params,
|
||||
)
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"""Runtime config planning and retry/fallback selection.
|
||||
|
||||
Owns:
|
||||
- Resolution of ConfiguredModelSettings → ModelConfig.
|
||||
- Per-attempt planning (AttemptPlan) including primary/fallback selection and
|
||||
reasoning-effort/thinking-budget resolution.
|
||||
- Per-call effective config construction (applying caller kwarg overrides onto
|
||||
the selected ModelConfig).
|
||||
- Retry attempt tracking via a ContextVar, plus the temperature-bump heuristic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from src.config import (
|
||||
ConfiguredModelSettings,
|
||||
ModelConfig,
|
||||
ModelTransport,
|
||||
resolve_model_config,
|
||||
)
|
||||
|
||||
from .registry import backend_for_provider, client_for_model_config
|
||||
from .types import ProviderClient, ReasoningEffortType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ContextVar tracking the current retry attempt for provider switching.
|
||||
current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttemptPlan:
|
||||
"""Per-attempt plan produced by `plan_attempt`.
|
||||
|
||||
Replaces the old loose tuple-of-six (`ProviderSelection`) with a single
|
||||
dataclass. Carries everything the executor / tool loop needs to make one
|
||||
backend call without re-resolving configuration mid-call.
|
||||
"""
|
||||
|
||||
provider: ModelTransport
|
||||
model: str
|
||||
client: ProviderClient
|
||||
thinking_budget_tokens: int | None
|
||||
reasoning_effort: ReasoningEffortType
|
||||
selected_config: ModelConfig
|
||||
|
||||
|
||||
def resolve_runtime_model_config(
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
) -> ModelConfig:
|
||||
"""Return a runtime ModelConfig, resolving settings-shape inputs if needed."""
|
||||
if isinstance(model_config, ModelConfig):
|
||||
return model_config
|
||||
return resolve_model_config(model_config)
|
||||
|
||||
|
||||
def select_model_config_for_attempt(
|
||||
model_config: ModelConfig,
|
||||
*,
|
||||
attempt: int,
|
||||
retry_attempts: int,
|
||||
) -> ModelConfig:
|
||||
"""Pick the effective config for this attempt.
|
||||
|
||||
Primary config on all attempts except the last, which swaps to the
|
||||
resolved fallback (if any).
|
||||
"""
|
||||
if attempt != retry_attempts or model_config.fallback is None:
|
||||
return model_config
|
||||
|
||||
fb = model_config.fallback
|
||||
return ModelConfig(
|
||||
model=fb.model,
|
||||
transport=fb.transport,
|
||||
fallback=None,
|
||||
api_key=fb.api_key,
|
||||
base_url=fb.base_url,
|
||||
temperature=fb.temperature,
|
||||
top_p=fb.top_p,
|
||||
top_k=fb.top_k,
|
||||
frequency_penalty=fb.frequency_penalty,
|
||||
presence_penalty=fb.presence_penalty,
|
||||
seed=fb.seed,
|
||||
thinking_effort=fb.thinking_effort,
|
||||
thinking_budget_tokens=fb.thinking_budget_tokens,
|
||||
provider_params=fb.provider_params,
|
||||
max_output_tokens=fb.max_output_tokens,
|
||||
stop_sequences=fb.stop_sequences,
|
||||
cache_policy=fb.cache_policy,
|
||||
)
|
||||
|
||||
|
||||
def plan_attempt(
|
||||
*,
|
||||
runtime_model_config: ModelConfig,
|
||||
attempt: int,
|
||||
retry_attempts: int,
|
||||
call_thinking_budget_tokens: int | None,
|
||||
call_reasoning_effort: ReasoningEffortType,
|
||||
) -> AttemptPlan:
|
||||
"""Build the AttemptPlan for `attempt`.
|
||||
|
||||
Reasoning params are drawn from the caller when we're still on the
|
||||
primary config, and from the fallback config otherwise, so cross-transport
|
||||
fallbacks use provider-appropriate params.
|
||||
"""
|
||||
selected = select_model_config_for_attempt(
|
||||
runtime_model_config,
|
||||
attempt=attempt,
|
||||
retry_attempts=retry_attempts,
|
||||
)
|
||||
provider = selected.transport
|
||||
client = client_for_model_config(provider, selected)
|
||||
|
||||
is_primary = selected is runtime_model_config
|
||||
attempt_thinking_budget = (
|
||||
call_thinking_budget_tokens if is_primary else selected.thinking_budget_tokens
|
||||
)
|
||||
attempt_reasoning_effort: ReasoningEffortType = (
|
||||
call_reasoning_effort if is_primary else selected.thinking_effort
|
||||
)
|
||||
|
||||
if attempt == retry_attempts and runtime_model_config.fallback is not None:
|
||||
logger.warning(
|
||||
f"Final retry attempt {attempt}/{retry_attempts}: switching from "
|
||||
+ f"{runtime_model_config.transport}/{runtime_model_config.model} to "
|
||||
+ f"backup {provider}/{selected.model}"
|
||||
)
|
||||
|
||||
return AttemptPlan(
|
||||
provider=provider,
|
||||
model=selected.model,
|
||||
client=client,
|
||||
thinking_budget_tokens=attempt_thinking_budget,
|
||||
reasoning_effort=attempt_reasoning_effort,
|
||||
selected_config=selected,
|
||||
)
|
||||
|
||||
|
||||
def effective_config_for_call(
|
||||
*,
|
||||
selected_config: ModelConfig | None,
|
||||
provider: ModelTransport,
|
||||
model: str,
|
||||
temperature: float | None,
|
||||
stop_seqs: list[str] | None,
|
||||
thinking_budget_tokens: int | None,
|
||||
reasoning_effort: ReasoningEffortType,
|
||||
) -> ModelConfig:
|
||||
"""Build the ModelConfig passed to the executor / request_builder.
|
||||
|
||||
Per-call kwargs (temperature, stop_seqs, thinking_*) win when set; otherwise
|
||||
the selected_config's values are used. When selected_config is None
|
||||
(test-only callers passing provider+model directly) a minimal ModelConfig
|
||||
is synthesized.
|
||||
|
||||
max_output_tokens is forced to None so the per-call max_tokens kwarg is
|
||||
authoritative — matching historical honcho_llm_call_inner behavior.
|
||||
"""
|
||||
if selected_config is None:
|
||||
return ModelConfig(
|
||||
model=model,
|
||||
transport=provider,
|
||||
temperature=temperature,
|
||||
stop_sequences=stop_seqs,
|
||||
thinking_budget_tokens=thinking_budget_tokens,
|
||||
thinking_effort=reasoning_effort,
|
||||
)
|
||||
updates: dict[str, Any] = {"max_output_tokens": None}
|
||||
if temperature is not None:
|
||||
updates["temperature"] = temperature
|
||||
if stop_seqs is not None:
|
||||
updates["stop_sequences"] = stop_seqs
|
||||
if thinking_budget_tokens is not None:
|
||||
updates["thinking_budget_tokens"] = thinking_budget_tokens
|
||||
if reasoning_effort is not None:
|
||||
updates["thinking_effort"] = reasoning_effort
|
||||
return selected_config.model_copy(update=updates)
|
||||
|
||||
|
||||
def effective_temperature(temperature: float | None) -> float | None:
|
||||
"""Bump temperature from 0.0 → 0.2 on retry attempts for variety."""
|
||||
if temperature == 0.0 and current_attempt.get() > 1:
|
||||
logger.debug("Bumping temperature from 0.0 to 0.2 on retry")
|
||||
return 0.2
|
||||
return temperature
|
||||
|
||||
|
||||
def resolve_backend_for_plan(plan: AttemptPlan) -> Any:
|
||||
"""Convenience helper: plan → ready-to-call ProviderBackend."""
|
||||
return backend_for_provider(plan.provider, plan.client)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttemptPlan",
|
||||
"current_attempt",
|
||||
"effective_config_for_call",
|
||||
"effective_temperature",
|
||||
"plan_attempt",
|
||||
"resolve_backend_for_plan",
|
||||
"resolve_runtime_model_config",
|
||||
"select_model_config_for_attempt",
|
||||
]
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.utils.json_parser import validate_and_repair_json
|
||||
from src.utils.representation import PromptRepresentation
|
||||
|
||||
from .backend import CompletionResult
|
||||
|
||||
StructuredOutputFailurePolicy = Literal[
|
||||
"raise",
|
||||
"repair_then_raise",
|
||||
"repair_then_empty",
|
||||
]
|
||||
|
||||
|
||||
class StructuredOutputError(ValueError):
|
||||
"""Raised when structured output cannot be validated or repaired."""
|
||||
|
||||
|
||||
def repair_response_model_json(
|
||||
raw_content: str,
|
||||
response_model: type[BaseModel],
|
||||
_model: str,
|
||||
) -> BaseModel:
|
||||
"""Repair truncated or malformed JSON and validate against the response model."""
|
||||
|
||||
try:
|
||||
final = validate_and_repair_json(raw_content)
|
||||
repaired_data = json.loads(final)
|
||||
|
||||
if (
|
||||
response_model is PromptRepresentation
|
||||
and "deductive" in repaired_data
|
||||
and isinstance(repaired_data["deductive"], list)
|
||||
):
|
||||
for item in repaired_data["deductive"]:
|
||||
if isinstance(item, dict):
|
||||
if "conclusion" not in item and "premises" in item:
|
||||
if item["premises"]:
|
||||
item["conclusion"] = (
|
||||
f"[Incomplete reasoning from premises: {item['premises'][0][:100]}...]"
|
||||
)
|
||||
else:
|
||||
item["conclusion"] = (
|
||||
"[Incomplete reasoning - conclusion missing]"
|
||||
)
|
||||
if "premises" not in item:
|
||||
item["premises"] = []
|
||||
|
||||
final = json.dumps(repaired_data)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
final = ""
|
||||
|
||||
try:
|
||||
return response_model.model_validate_json(final)
|
||||
except ValidationError:
|
||||
if response_model is PromptRepresentation:
|
||||
return PromptRepresentation(explicit=[])
|
||||
raise
|
||||
|
||||
|
||||
def validate_structured_output(
|
||||
content: object,
|
||||
response_model: type[BaseModel],
|
||||
) -> BaseModel:
|
||||
if isinstance(content, response_model):
|
||||
return content
|
||||
if isinstance(content, str):
|
||||
return response_model.model_validate_json(content)
|
||||
if isinstance(content, dict):
|
||||
return response_model.model_validate(content)
|
||||
raise StructuredOutputError(
|
||||
f"Unsupported structured output payload: {type(content).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def attempt_structured_output_repair(
|
||||
content: object,
|
||||
response_model: type[BaseModel],
|
||||
model: str,
|
||||
) -> BaseModel | None:
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
try:
|
||||
return repair_response_model_json(content, response_model, model)
|
||||
except (StructuredOutputError, ValidationError):
|
||||
return None
|
||||
|
||||
|
||||
def empty_structured_output(response_model: type[BaseModel]) -> BaseModel:
|
||||
if response_model is PromptRepresentation:
|
||||
return PromptRepresentation(explicit=[])
|
||||
return response_model.model_validate({})
|
||||
|
||||
|
||||
async def execute_structured_output_call(
|
||||
executor: Callable[[], Awaitable[CompletionResult]],
|
||||
*,
|
||||
response_model: type[BaseModel],
|
||||
model_name: str,
|
||||
failure_policy: StructuredOutputFailurePolicy = "repair_then_raise",
|
||||
) -> CompletionResult:
|
||||
result = await executor()
|
||||
|
||||
try:
|
||||
result.content = validate_structured_output(result.content, response_model)
|
||||
return result
|
||||
except (StructuredOutputError, ValidationError):
|
||||
if failure_policy == "raise":
|
||||
raise
|
||||
|
||||
repaired = attempt_structured_output_repair(
|
||||
result.content,
|
||||
response_model,
|
||||
model_name,
|
||||
)
|
||||
if repaired is not None:
|
||||
result.content = repaired
|
||||
return result
|
||||
|
||||
if failure_policy == "repair_then_empty":
|
||||
result.content = empty_structured_output(response_model)
|
||||
return result
|
||||
|
||||
raise StructuredOutputError(
|
||||
f"Failed to produce valid structured output for {model_name}"
|
||||
)
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
"""Agentic/tool orchestration — the multi-iteration tool execution loop.
|
||||
|
||||
`execute_tool_loop` owns:
|
||||
- initial tool-enabled call
|
||||
- tool execution
|
||||
- conversation augmentation with assistant messages + tool results
|
||||
- max-iteration handling and synthesis call
|
||||
- stream-final-only mode
|
||||
- empty-response retry (one retry nudge when the model returns empty content)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from src.config import ModelTransport
|
||||
from src.exceptions import ValidationException
|
||||
from src.utils.types import set_current_iteration
|
||||
|
||||
from .executor import honcho_llm_call_inner
|
||||
from .registry import history_adapter_for_provider
|
||||
from .runtime import (
|
||||
AttemptPlan,
|
||||
current_attempt,
|
||||
effective_temperature,
|
||||
)
|
||||
from .types import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
IterationCallback,
|
||||
IterationData,
|
||||
StreamingResponseWithMetadata,
|
||||
VerbosityType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bounds for max_tool_iterations to prevent runaway loops.
|
||||
MIN_TOOL_ITERATIONS = 1
|
||||
MAX_TOOL_ITERATIONS = 100
|
||||
|
||||
|
||||
def format_assistant_tool_message(
|
||||
provider: ModelTransport,
|
||||
content: Any,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
thinking_blocks: list[dict[str, Any]] | None = None,
|
||||
reasoning_details: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Format an assistant message with tool calls in provider-native shape."""
|
||||
from .backend import CompletionResult as BackendCompletionResult
|
||||
from .backend import ToolCallResult
|
||||
|
||||
adapter = history_adapter_for_provider(provider)
|
||||
result = BackendCompletionResult(
|
||||
content=content,
|
||||
tool_calls=[
|
||||
ToolCallResult(
|
||||
id=tool_call["id"],
|
||||
name=tool_call["name"],
|
||||
input=tool_call["input"],
|
||||
thought_signature=tool_call.get("thought_signature"),
|
||||
)
|
||||
for tool_call in tool_calls
|
||||
],
|
||||
thinking_blocks=thinking_blocks or [],
|
||||
reasoning_details=reasoning_details or [],
|
||||
)
|
||||
return adapter.format_assistant_tool_message(result)
|
||||
|
||||
|
||||
def append_tool_results(
|
||||
provider: ModelTransport,
|
||||
tool_results: list[dict[str, Any]],
|
||||
conversation_messages: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Append tool results to `conversation_messages` in provider-native shape."""
|
||||
adapter = history_adapter_for_provider(provider)
|
||||
conversation_messages.extend(adapter.format_tool_results(tool_results))
|
||||
|
||||
|
||||
async def stream_final_response(
|
||||
*,
|
||||
winning_plan: AttemptPlan,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
conversation_messages: list[dict[str, Any]],
|
||||
response_model: type[BaseModel] | None,
|
||||
json_mode: bool,
|
||||
temperature: float | None,
|
||||
stop_seqs: list[str] | None,
|
||||
verbosity: VerbosityType,
|
||||
enable_retry: bool,
|
||||
retry_attempts: int,
|
||||
before_retry_callback: Callable[[Any], None],
|
||||
) -> AsyncIterator[HonchoLLMCallStreamChunk]:
|
||||
"""Stream the final response after tool execution is complete.
|
||||
|
||||
Uses the AttemptPlan captured at the moment streaming began (typically
|
||||
the plan whose inner LLM call just succeeded) and pins it across any
|
||||
retries of the stream setup. Re-running provider selection here would
|
||||
bleed the outer current_attempt ContextVar into streaming retries,
|
||||
potentially rolling the selection back to primary after the tool loop
|
||||
had already settled on fallback. Tenacity retries re-issue the same
|
||||
streaming call against the same pinned model for transient errors.
|
||||
"""
|
||||
|
||||
async def _setup_stream() -> AsyncIterator[HonchoLLMCallStreamChunk]:
|
||||
return await honcho_llm_call_inner(
|
||||
winning_plan.provider,
|
||||
winning_plan.model,
|
||||
prompt,
|
||||
max_tokens,
|
||||
response_model,
|
||||
json_mode,
|
||||
effective_temperature(temperature),
|
||||
stop_seqs,
|
||||
winning_plan.reasoning_effort,
|
||||
verbosity,
|
||||
winning_plan.thinking_budget_tokens,
|
||||
stream=True,
|
||||
client_override=winning_plan.client,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
messages=conversation_messages,
|
||||
selected_config=winning_plan.selected_config,
|
||||
)
|
||||
|
||||
if enable_retry:
|
||||
wrapped = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
before_sleep=before_retry_callback,
|
||||
)(_setup_stream)
|
||||
stream = await wrapped()
|
||||
else:
|
||||
stream = await _setup_stream()
|
||||
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
|
||||
async def execute_tool_loop(
|
||||
*,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
messages: list[dict[str, Any]] | None,
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
tool_executor: Callable[[str, dict[str, Any]], Any],
|
||||
max_tool_iterations: int,
|
||||
response_model: type[BaseModel] | None,
|
||||
json_mode: bool,
|
||||
temperature: float | None,
|
||||
stop_seqs: list[str] | None,
|
||||
verbosity: VerbosityType,
|
||||
enable_retry: bool,
|
||||
retry_attempts: int,
|
||||
max_input_tokens: int | None,
|
||||
get_attempt_plan: Callable[[], AttemptPlan],
|
||||
before_retry_callback: Callable[[Any], None],
|
||||
stream_final: bool = False,
|
||||
iteration_callback: IterationCallback | None = None,
|
||||
) -> HonchoLLMCallResponse[Any] | StreamingResponseWithMetadata:
|
||||
"""Run the iterative tool calling loop for agentic LLM interactions.
|
||||
|
||||
Loop per iteration:
|
||||
1. Make an LLM call with tools available
|
||||
2. Execute any tool calls the LLM requests
|
||||
3. Append tool results to the conversation
|
||||
4. Repeat until the LLM stops calling tools or max iterations reached
|
||||
|
||||
Returns:
|
||||
Final HonchoLLMCallResponse with accumulated token counts and tool call
|
||||
history, or a StreamingResponseWithMetadata if stream_final=True.
|
||||
"""
|
||||
from .conversation import truncate_messages_to_fit
|
||||
|
||||
if not MIN_TOOL_ITERATIONS <= max_tool_iterations <= MAX_TOOL_ITERATIONS:
|
||||
raise ValidationException(
|
||||
"max_tool_iterations must be in "
|
||||
+ f"[{MIN_TOOL_ITERATIONS}, {MAX_TOOL_ITERATIONS}]; "
|
||||
+ f"got {max_tool_iterations}"
|
||||
)
|
||||
|
||||
conversation_messages: list[dict[str, Any]] = (
|
||||
messages.copy() if messages else [{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
iteration = 0
|
||||
all_tool_calls: list[dict[str, Any]] = []
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
total_cache_creation_tokens = 0
|
||||
total_cache_read_tokens = 0
|
||||
empty_response_retries = 0
|
||||
# Track effective tool_choice — switches from "required"/"any" to "auto" after iter 1.
|
||||
effective_tool_choice = tool_choice
|
||||
|
||||
while iteration < max_tool_iterations:
|
||||
# Reset attempt counter so each iteration starts with the primary provider.
|
||||
current_attempt.set(1)
|
||||
logger.debug(f"Tool execution iteration {iteration + 1}/{max_tool_iterations}")
|
||||
|
||||
if max_input_tokens is not None:
|
||||
conversation_messages = truncate_messages_to_fit(
|
||||
conversation_messages, max_input_tokens
|
||||
)
|
||||
|
||||
async def _call_with_messages(
|
||||
effective_tool_choice: str | dict[str, Any] | None = effective_tool_choice,
|
||||
conversation_messages: list[dict[str, Any]] = conversation_messages,
|
||||
) -> HonchoLLMCallResponse[Any]:
|
||||
plan = get_attempt_plan()
|
||||
return await honcho_llm_call_inner(
|
||||
plan.provider,
|
||||
plan.model,
|
||||
prompt, # ignored when messages is passed
|
||||
max_tokens,
|
||||
response_model,
|
||||
json_mode,
|
||||
effective_temperature(temperature),
|
||||
stop_seqs,
|
||||
plan.reasoning_effort,
|
||||
verbosity,
|
||||
plan.thinking_budget_tokens,
|
||||
stream=False,
|
||||
client_override=plan.client,
|
||||
tools=tools,
|
||||
tool_choice=effective_tool_choice,
|
||||
messages=conversation_messages,
|
||||
selected_config=plan.selected_config,
|
||||
)
|
||||
|
||||
if enable_retry:
|
||||
call_func = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
before_sleep=before_retry_callback,
|
||||
)(_call_with_messages)
|
||||
else:
|
||||
call_func = _call_with_messages
|
||||
|
||||
response = await call_func()
|
||||
|
||||
total_input_tokens += response.input_tokens
|
||||
total_output_tokens += response.output_tokens
|
||||
total_cache_creation_tokens += response.cache_creation_input_tokens
|
||||
total_cache_read_tokens += response.cache_read_input_tokens
|
||||
|
||||
if not response.tool_calls_made:
|
||||
logger.debug("No tool calls in response, finishing")
|
||||
|
||||
if (
|
||||
isinstance(response.content, str)
|
||||
and not response.content.strip()
|
||||
and empty_response_retries < 1
|
||||
and iteration < max_tool_iterations - 1
|
||||
):
|
||||
empty_response_retries += 1
|
||||
conversation_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your last response was empty. Provide a concise answer "
|
||||
"to the original query using the available context."
|
||||
),
|
||||
}
|
||||
)
|
||||
iteration += 1
|
||||
continue
|
||||
|
||||
if stream_final:
|
||||
# Snapshot the plan that just succeeded — streaming retries
|
||||
# pin to this exact client/model so we don't bounce back to
|
||||
# primary after the tool loop settled on fallback.
|
||||
winning_plan = get_attempt_plan()
|
||||
stream = stream_final_response(
|
||||
winning_plan=winning_plan,
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
conversation_messages=conversation_messages,
|
||||
response_model=response_model,
|
||||
json_mode=json_mode,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
verbosity=verbosity,
|
||||
enable_retry=enable_retry,
|
||||
retry_attempts=retry_attempts,
|
||||
before_retry_callback=before_retry_callback,
|
||||
)
|
||||
return StreamingResponseWithMetadata(
|
||||
stream=stream,
|
||||
tool_calls_made=all_tool_calls,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
cache_creation_input_tokens=total_cache_creation_tokens,
|
||||
cache_read_input_tokens=total_cache_read_tokens,
|
||||
thinking_content=response.thinking_content,
|
||||
iterations=iteration + 1,
|
||||
)
|
||||
|
||||
response.tool_calls_made = all_tool_calls
|
||||
response.input_tokens = total_input_tokens
|
||||
response.output_tokens = total_output_tokens
|
||||
response.cache_creation_input_tokens = total_cache_creation_tokens
|
||||
response.cache_read_input_tokens = total_cache_read_tokens
|
||||
response.iterations = iteration + 1
|
||||
return response
|
||||
|
||||
current_provider = get_attempt_plan().provider
|
||||
|
||||
assistant_message = format_assistant_tool_message(
|
||||
current_provider,
|
||||
response.content,
|
||||
response.tool_calls_made,
|
||||
response.thinking_blocks,
|
||||
response.reasoning_details,
|
||||
)
|
||||
conversation_messages.append(assistant_message)
|
||||
|
||||
# Telemetry context — 1-indexed iteration.
|
||||
set_current_iteration(iteration + 1)
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tool_call in response.tool_calls_made:
|
||||
tool_name = tool_call["name"]
|
||||
tool_input = tool_call["input"]
|
||||
tool_id = tool_call.get("id", "")
|
||||
|
||||
logger.debug(f"Executing tool: {tool_name}")
|
||||
|
||||
try:
|
||||
tool_result = await tool_executor(tool_name, tool_input)
|
||||
tool_results.append(
|
||||
{
|
||||
"tool_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"result": tool_result,
|
||||
}
|
||||
)
|
||||
all_tool_calls.append(
|
||||
{
|
||||
"tool_name": tool_name,
|
||||
"tool_input": tool_input,
|
||||
"tool_result": tool_result,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Tool execution failed for {tool_name}: {e}")
|
||||
tool_results.append(
|
||||
{
|
||||
"tool_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"result": f"Error: {str(e)}",
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
|
||||
append_tool_results(current_provider, tool_results, conversation_messages)
|
||||
|
||||
if iteration_callback is not None:
|
||||
try:
|
||||
iteration_data = IterationData(
|
||||
iteration=iteration + 1,
|
||||
tool_calls=[tc["name"] for tc in response.tool_calls_made],
|
||||
input_tokens=response.input_tokens,
|
||||
output_tokens=response.output_tokens,
|
||||
cache_read_tokens=response.cache_read_input_tokens or 0,
|
||||
cache_creation_tokens=response.cache_creation_input_tokens or 0,
|
||||
)
|
||||
iteration_callback(iteration_data)
|
||||
except Exception:
|
||||
logger.warning("iteration_callback failed", exc_info=True)
|
||||
|
||||
# After first iteration, switch "required"/"any" → "auto" so the model can stop.
|
||||
if iteration == 0 and effective_tool_choice in ("required", "any"):
|
||||
effective_tool_choice = "auto"
|
||||
logger.debug(
|
||||
"Switched tool_choice from 'required'/'any' to 'auto' after first iteration"
|
||||
)
|
||||
|
||||
iteration += 1
|
||||
|
||||
logger.warning(
|
||||
f"Tool execution loop reached max iterations ({max_tool_iterations})"
|
||||
)
|
||||
|
||||
synthesis_prompt = (
|
||||
"You have reached the maximum number of tool calls. "
|
||||
"Based on all the information you have gathered, provide your final response now. "
|
||||
"Do not attempt to call any more tools."
|
||||
)
|
||||
conversation_messages.append({"role": "user", "content": synthesis_prompt})
|
||||
|
||||
# Truncate again — the per-iteration truncate ran before the last tool
|
||||
# call, so appending synthesis_prompt could nudge us back over the cap.
|
||||
if max_input_tokens is not None:
|
||||
conversation_messages = truncate_messages_to_fit(
|
||||
conversation_messages, max_input_tokens
|
||||
)
|
||||
|
||||
if stream_final:
|
||||
# Snapshot the plan the loop settled on — streaming retries pin to
|
||||
# this exact client/model rather than re-running provider selection.
|
||||
winning_plan = get_attempt_plan()
|
||||
stream = stream_final_response(
|
||||
winning_plan=winning_plan,
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
conversation_messages=conversation_messages,
|
||||
response_model=response_model,
|
||||
json_mode=json_mode,
|
||||
temperature=temperature,
|
||||
stop_seqs=stop_seqs,
|
||||
verbosity=verbosity,
|
||||
enable_retry=enable_retry,
|
||||
retry_attempts=retry_attempts,
|
||||
before_retry_callback=before_retry_callback,
|
||||
)
|
||||
return StreamingResponseWithMetadata(
|
||||
stream=stream,
|
||||
tool_calls_made=all_tool_calls,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
cache_creation_input_tokens=total_cache_creation_tokens,
|
||||
cache_read_input_tokens=total_cache_read_tokens,
|
||||
thinking_content=None,
|
||||
iterations=iteration + 1,
|
||||
)
|
||||
|
||||
current_attempt.set(1)
|
||||
|
||||
async def _final_call() -> HonchoLLMCallResponse[Any]:
|
||||
plan = get_attempt_plan()
|
||||
return await honcho_llm_call_inner(
|
||||
plan.provider,
|
||||
plan.model,
|
||||
prompt,
|
||||
max_tokens,
|
||||
response_model,
|
||||
json_mode,
|
||||
effective_temperature(temperature),
|
||||
stop_seqs,
|
||||
plan.reasoning_effort,
|
||||
verbosity,
|
||||
plan.thinking_budget_tokens,
|
||||
stream=False,
|
||||
client_override=plan.client,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
messages=conversation_messages,
|
||||
selected_config=plan.selected_config,
|
||||
)
|
||||
|
||||
if enable_retry:
|
||||
final_call_func = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
before_sleep=before_retry_callback,
|
||||
)(_final_call)
|
||||
else:
|
||||
final_call_func = _final_call
|
||||
|
||||
final_response = await final_call_func()
|
||||
final_response.tool_calls_made = all_tool_calls
|
||||
final_response.iterations = iteration + 1
|
||||
final_response.input_tokens = total_input_tokens + final_response.input_tokens
|
||||
final_response.output_tokens = total_output_tokens + final_response.output_tokens
|
||||
final_response.cache_creation_input_tokens = (
|
||||
total_cache_creation_tokens + final_response.cache_creation_input_tokens
|
||||
)
|
||||
final_response.cache_read_input_tokens = (
|
||||
total_cache_read_tokens + final_response.cache_read_input_tokens
|
||||
)
|
||||
return final_response
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_TOOL_ITERATIONS",
|
||||
"MIN_TOOL_ITERATIONS",
|
||||
"append_tool_results",
|
||||
"execute_tool_loop",
|
||||
"format_assistant_tool_message",
|
||||
"stream_final_response",
|
||||
]
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"""Public response/stream/iteration types for the LLM API.
|
||||
|
||||
These used to live in src/utils/clients.py and have been moved here as part
|
||||
of the migration toward src/llm/ owning all non-embedding LLM orchestration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# OpenAI GPT-5 specific reasoning levels.
|
||||
ReasoningEffortType = (
|
||||
Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
||||
)
|
||||
VerbosityType = Literal["low", "medium", "high"] | None
|
||||
|
||||
# Raw SDK client union used by the provider-selection layer.
|
||||
ProviderClient = AsyncAnthropic | AsyncOpenAI | genai.Client
|
||||
|
||||
|
||||
@dataclass
|
||||
class IterationData:
|
||||
"""Data passed to iteration callbacks after each tool execution loop iteration."""
|
||||
|
||||
iteration: int
|
||||
"""1-indexed iteration number."""
|
||||
tool_calls: list[str]
|
||||
"""List of tool names called in this iteration."""
|
||||
input_tokens: int
|
||||
"""Input tokens used in this iteration's LLM call."""
|
||||
output_tokens: int
|
||||
"""Output tokens generated in this iteration's LLM call."""
|
||||
cache_read_tokens: int = 0
|
||||
"""Tokens read from cache in this iteration."""
|
||||
cache_creation_tokens: int = 0
|
||||
"""Tokens written to cache in this iteration."""
|
||||
|
||||
|
||||
IterationCallback = Callable[[IterationData], None]
|
||||
|
||||
|
||||
class HonchoLLMCallResponse(BaseModel, Generic[T]):
|
||||
"""Response object for LLM calls.
|
||||
|
||||
Note:
|
||||
Uncached input tokens = input_tokens - cache_read_input_tokens
|
||||
+ cache_creation_input_tokens
|
||||
(cache_creation costs 25% more, cache_read costs 90% less)
|
||||
"""
|
||||
|
||||
content: T
|
||||
input_tokens: int = 0
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int = 0
|
||||
cache_read_input_tokens: int = 0
|
||||
finish_reasons: list[str]
|
||||
tool_calls_made: list[dict[str, Any]] = Field(default_factory=list)
|
||||
iterations: int = 0
|
||||
"""Number of LLM calls made in the tool execution loop."""
|
||||
thinking_content: str | None = None
|
||||
# Full thinking blocks with signatures for multi-turn replay (Anthropic only).
|
||||
thinking_blocks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# OpenRouter reasoning_details for Gemini models — must be preserved across turns.
|
||||
reasoning_details: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HonchoLLMCallStreamChunk(BaseModel):
|
||||
"""A single chunk in a streaming LLM response."""
|
||||
|
||||
content: str
|
||||
is_done: bool = False
|
||||
finish_reasons: list[str] = Field(default_factory=list)
|
||||
output_tokens: int | None = None
|
||||
|
||||
|
||||
class StreamingResponseWithMetadata:
|
||||
"""Streaming response wrapper carrying metadata from a completed tool loop.
|
||||
|
||||
Lets callers read tool_calls_made / token counts / thinking_content from
|
||||
the tool-execution phase while still iterating the final streamed answer.
|
||||
"""
|
||||
|
||||
_stream: AsyncIterator[HonchoLLMCallStreamChunk]
|
||||
tool_calls_made: list[dict[str, Any]]
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int
|
||||
cache_read_input_tokens: int
|
||||
thinking_content: str | None
|
||||
iterations: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: AsyncIterator[HonchoLLMCallStreamChunk],
|
||||
tool_calls_made: list[dict[str, Any]],
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cache_creation_input_tokens: int,
|
||||
cache_read_input_tokens: int,
|
||||
thinking_content: str | None = None,
|
||||
iterations: int = 0,
|
||||
):
|
||||
self._stream = stream
|
||||
self.tool_calls_made = tool_calls_made
|
||||
self.input_tokens = input_tokens
|
||||
self.output_tokens = output_tokens
|
||||
self.cache_creation_input_tokens = cache_creation_input_tokens
|
||||
self.cache_read_input_tokens = cache_read_input_tokens
|
||||
self.thinking_content = thinking_content
|
||||
self.iterations = iterations
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[HonchoLLMCallStreamChunk]:
|
||||
return self._stream.__aiter__()
|
||||
|
||||
async def __anext__(self) -> HonchoLLMCallStreamChunk:
|
||||
return await self._stream.__anext__()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HonchoLLMCallResponse",
|
||||
"HonchoLLMCallStreamChunk",
|
||||
"IterationCallback",
|
||||
"IterationData",
|
||||
"ProviderClient",
|
||||
"ReasoningEffortType",
|
||||
"StreamingResponseWithMetadata",
|
||||
"T",
|
||||
"VerbosityType",
|
||||
]
|
||||
|
|
@ -501,9 +501,10 @@ class ConclusionCreate(BaseModel):
|
|||
tokens = encoding.encode(self.content)
|
||||
self._token_count = len(tokens)
|
||||
|
||||
if self._token_count > settings.MAX_EMBEDDING_TOKENS:
|
||||
if self._token_count > settings.EMBEDDING.MAX_INPUT_TOKENS:
|
||||
raise ValueError(
|
||||
f"Content exceeds maximum embedding token limit of {settings.MAX_EMBEDDING_TOKENS} "
|
||||
"Content exceeds maximum embedding token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS} "
|
||||
+ f"(got {self._token_count} tokens)"
|
||||
)
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ from typing import Any
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import LLMComponentSettings, settings
|
||||
from src.config import (
|
||||
ConfiguredModelSettings,
|
||||
ModelConfig,
|
||||
settings,
|
||||
)
|
||||
|
||||
|
||||
def get_reasoning_traces_file_path() -> Path | None:
|
||||
|
|
@ -24,7 +28,7 @@ def get_reasoning_traces_file_path() -> Path | None:
|
|||
|
||||
def log_reasoning_trace(
|
||||
task_type: str,
|
||||
llm_settings: LLMComponentSettings,
|
||||
model_config: ModelConfig | ConfiguredModelSettings,
|
||||
prompt: str,
|
||||
response: Any,
|
||||
*,
|
||||
|
|
@ -40,7 +44,7 @@ def log_reasoning_trace(
|
|||
|
||||
Args:
|
||||
task_type: Type of task (e.g., "minimal_deriver", "dialectic_chat")
|
||||
llm_settings: LLM settings used for the call
|
||||
model_config: Model configuration used for the call
|
||||
prompt: The full prompt text sent to the LLM (used if messages is None)
|
||||
response: HonchoLLMCallResponse object with the LLM response
|
||||
max_tokens: Max output tokens setting
|
||||
|
|
@ -62,8 +66,8 @@ def log_reasoning_trace(
|
|||
trace_entry: dict[str, Any] = {
|
||||
"timestamp": time.time(),
|
||||
"task_type": task_type,
|
||||
"provider": llm_settings.PROVIDER,
|
||||
"model": llm_settings.MODEL,
|
||||
"provider": model_config.transport,
|
||||
"model": model_config.model,
|
||||
"settings": {
|
||||
"max_tokens": max_tokens,
|
||||
"thinking_budget_tokens": thinking_budget_tokens,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,205 @@ logger = logging.getLogger(__name__)
|
|||
MAX_PEER_CARD_FACTS = 40
|
||||
|
||||
|
||||
def _base_observation_properties() -> dict[str, Any]:
|
||||
return {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The observation content",
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"explicit",
|
||||
"deductive",
|
||||
"inductive",
|
||||
"contradiction",
|
||||
],
|
||||
"description": (
|
||||
"Level: 'explicit' for direct facts, 'deductive' for logical "
|
||||
+ "necessities, 'inductive' for patterns, 'contradiction' for "
|
||||
+ "conflicting statements"
|
||||
),
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Document IDs of source or premise observations. Required and "
|
||||
+ "must be non-empty for deductive, inductive, and contradiction "
|
||||
+ "observations."
|
||||
),
|
||||
},
|
||||
"premises": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(For deductive) Human-readable premise text for display",
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(For inductive/contradiction) Human-readable source text for display",
|
||||
},
|
||||
"pattern_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preference",
|
||||
"behavior",
|
||||
"personality",
|
||||
"tendency",
|
||||
"correlation",
|
||||
],
|
||||
"description": "(For inductive only) Type of pattern being identified",
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": (
|
||||
"(For inductive only) Confidence level: 'high' for 5+ sources, "
|
||||
+ "'medium' for 3-4, 'low' for 2"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _generic_observation_item_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": _base_observation_properties(),
|
||||
"required": ["content", "level"],
|
||||
"additionalProperties": False,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"level": {"const": "deductive"}}},
|
||||
"then": {
|
||||
"required": ["source_ids", "premises"],
|
||||
"properties": {
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
},
|
||||
"premises": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"level": {"const": "inductive"}}},
|
||||
"then": {
|
||||
"required": [
|
||||
"source_ids",
|
||||
"sources",
|
||||
"pattern_type",
|
||||
"confidence",
|
||||
],
|
||||
"properties": {
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"level": {"const": "contradiction"}}},
|
||||
"then": {
|
||||
"required": ["source_ids", "sources"],
|
||||
"properties": {
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _deductive_observation_item_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The deductive conclusion as a self-contained statement",
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"description": "Required non-empty list of source observation IDs supporting the deduction",
|
||||
},
|
||||
"premises": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"description": "Required human-readable premise text matching the source observations",
|
||||
},
|
||||
},
|
||||
"required": ["content", "source_ids", "premises"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _inductive_observation_item_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The inductive pattern or generalization as a self-contained statement",
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
"description": "Required list of at least two source observation IDs supporting the pattern",
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
"description": "Required human-readable evidence text matching the source observations",
|
||||
},
|
||||
"pattern_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preference",
|
||||
"behavior",
|
||||
"personality",
|
||||
"tendency",
|
||||
"correlation",
|
||||
],
|
||||
"description": "Required pattern category",
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": "Required confidence level based on evidence count",
|
||||
},
|
||||
},
|
||||
"required": ["content", "source_ids", "sources", "pattern_type", "confidence"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int) -> int:
|
||||
"""Coerce a tool input value to int, returning default on failure.
|
||||
|
||||
|
|
@ -177,88 +376,44 @@ def _extract_pattern_snippet(
|
|||
TOOLS: dict[str, dict[str, Any]] = {
|
||||
"create_observations": {
|
||||
"name": "create_observations",
|
||||
"description": "Create observations at any level: explicit (facts), deductive (logical necessities), inductive (patterns), or contradiction (conflicting statements). Use this to record facts, logical inferences, patterns, or note when the user has said contradictory things.",
|
||||
"description": "Create observations at any level: explicit (facts), deductive (logical necessities), inductive (patterns), or contradiction (conflicting statements). For deductive, inductive, and contradiction observations, missing or empty source_ids are invalid and will be rejected.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"description": "List of observations to create",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The observation content",
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"explicit",
|
||||
"deductive",
|
||||
"inductive",
|
||||
"contradiction",
|
||||
],
|
||||
"description": "Level: 'explicit' for direct facts, 'deductive' for logical necessities, 'inductive' for patterns, 'contradiction' for conflicting statements",
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(For deductive/inductive/contradiction) Document IDs of source/premise observations - REQUIRED",
|
||||
},
|
||||
"premises": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(For deductive) Human-readable premise text for display",
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(For inductive/contradiction) Human-readable source text for display",
|
||||
},
|
||||
"pattern_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preference",
|
||||
"behavior",
|
||||
"personality",
|
||||
"tendency",
|
||||
"correlation",
|
||||
],
|
||||
"description": "(For inductive only) Type of pattern being identified",
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": "(For inductive only) Confidence level: 'high' for 3+ sources, 'medium' for 2+, 'low' for tentative",
|
||||
},
|
||||
},
|
||||
"required": ["content", "level"],
|
||||
},
|
||||
"items": _generic_observation_item_schema(),
|
||||
},
|
||||
},
|
||||
"required": ["observations"],
|
||||
},
|
||||
},
|
||||
"create_observations_deductive": {
|
||||
"name": "create_observations",
|
||||
"description": "Create new deductive observations discovered while answering the query. Use this when you infer something new about the peer that isn't already captured in existing observations. Only use for novel deductions - not for restating existing facts.",
|
||||
"name": "create_observations_deductive",
|
||||
"description": "Create new deductive observations discovered while answering the query. Every observation must include non-empty source_ids and premise text. Use this only for novel deductions grounded in existing observations.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"description": "List of new deductive observations to create",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The observation content - should be a self-contained statement about the peer",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
"items": _deductive_observation_item_schema(),
|
||||
},
|
||||
},
|
||||
"required": ["observations"],
|
||||
},
|
||||
},
|
||||
"create_observations_inductive": {
|
||||
"name": "create_observations_inductive",
|
||||
"description": "Create new inductive observations discovered while answering the query. Every observation must include source_ids, source text, pattern_type, and confidence. Use this only for patterns supported by multiple observations.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"description": "List of new inductive observations to create",
|
||||
"items": _inductive_observation_item_schema(),
|
||||
},
|
||||
},
|
||||
"required": ["observations"],
|
||||
|
|
@ -595,7 +750,7 @@ DEDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [
|
|||
TOOLS["search_memory"],
|
||||
TOOLS["search_messages"],
|
||||
# Action tools
|
||||
TOOLS["create_observations"],
|
||||
TOOLS["create_observations_deductive"],
|
||||
TOOLS["delete_observations"],
|
||||
TOOLS["update_peer_card"],
|
||||
]
|
||||
|
|
@ -610,7 +765,7 @@ INDUCTION_SPECIALIST_TOOLS: list[dict[str, Any]] = [
|
|||
TOOLS["search_memory"],
|
||||
TOOLS["search_messages"],
|
||||
# Action tools
|
||||
TOOLS["create_observations"],
|
||||
TOOLS["create_observations_inductive"],
|
||||
TOOLS["update_peer_card"],
|
||||
]
|
||||
|
||||
|
|
@ -1033,8 +1188,11 @@ class ToolContext:
|
|||
parent_category: str | None = None # Parent category for CloudEvents
|
||||
|
||||
|
||||
async def _handle_create_observations(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
async def _handle_create_observations_impl(
|
||||
ctx: ToolContext,
|
||||
tool_input: dict[str, Any],
|
||||
*,
|
||||
forced_level: str | None = None,
|
||||
) -> str:
|
||||
"""Handle create_observations tool."""
|
||||
raw_observations = tool_input.get("observations", [])
|
||||
|
|
@ -1045,7 +1203,10 @@ async def _handle_create_observations(
|
|||
# Set context-specific default level before Pydantic validation
|
||||
default_level = "explicit" if ctx.current_messages else "deductive"
|
||||
for obs in raw_observations:
|
||||
obs.setdefault("level", default_level)
|
||||
if forced_level is not None:
|
||||
obs["level"] = forced_level
|
||||
else:
|
||||
obs.setdefault("level", default_level)
|
||||
|
||||
# Validate observations individually so valid ones are still processed
|
||||
observations: list[schemas.ObservationInput] = []
|
||||
|
|
@ -1139,6 +1300,32 @@ async def _handle_create_observations(
|
|||
return response
|
||||
|
||||
|
||||
async def _handle_create_observations(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> str:
|
||||
return await _handle_create_observations_impl(ctx, tool_input)
|
||||
|
||||
|
||||
async def _handle_create_observations_deductive(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> str:
|
||||
return await _handle_create_observations_impl(
|
||||
ctx,
|
||||
tool_input,
|
||||
forced_level="deductive",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_create_observations_inductive(
|
||||
ctx: ToolContext, tool_input: dict[str, Any]
|
||||
) -> str:
|
||||
return await _handle_create_observations_impl(
|
||||
ctx,
|
||||
tool_input,
|
||||
forced_level="inductive",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_update_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str:
|
||||
"""Handle update_peer_card tool."""
|
||||
# Check if peer card creation is disabled via configuration
|
||||
|
|
@ -1263,7 +1450,10 @@ async def _handle_search_memory(ctx: ToolContext, tool_input: dict[str, Any]) ->
|
|||
try:
|
||||
query_embedding = await embedding_client.embed(query)
|
||||
except ValueError:
|
||||
return f"ERROR: Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}. Please use a shorter query."
|
||||
return (
|
||||
"ERROR: Query exceeds maximum token limit of "
|
||||
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query."
|
||||
)
|
||||
|
||||
documents = await crud.query_documents(
|
||||
db=None,
|
||||
|
|
@ -1814,6 +2004,8 @@ async def _handle_get_reasoning_chain(
|
|||
# Tool handler dispatch table
|
||||
_TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = {
|
||||
"create_observations": _handle_create_observations,
|
||||
"create_observations_deductive": _handle_create_observations_deductive,
|
||||
"create_observations_inductive": _handle_create_observations_inductive,
|
||||
"update_peer_card": _handle_update_peer_card,
|
||||
"get_recent_history": _handle_get_recent_history,
|
||||
"search_memory": _handle_search_memory,
|
||||
|
|
|
|||
2575
src/utils/clients.py
2575
src/utils/clients.py
File diff suppressed because it is too large
Load Diff
|
|
@ -382,7 +382,7 @@ async def search(
|
|||
query_embedding = await embedding_client.embed(query)
|
||||
except ValueError as e:
|
||||
raise ValidationException(
|
||||
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
|
||||
f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}."
|
||||
) from e
|
||||
|
||||
if not _uses_pgvector_message_search():
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import schemas
|
||||
from src.cache.client import cache as cache_client
|
||||
from src.config import settings
|
||||
from src.config import ConfiguredModelSettings, settings
|
||||
from src.crud.session import session_cache_key
|
||||
from src.dependencies import tracked_db
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
from src.llm import HonchoLLMCallResponse, honcho_llm_call
|
||||
from src.models import Message
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import AgentToolSummaryCreatedEvent, emit
|
||||
|
|
@ -24,7 +25,6 @@ from src.telemetry.prometheus.metrics import (
|
|||
DeriverTaskTypes,
|
||||
TokenTypes,
|
||||
)
|
||||
from src.utils.clients import HonchoLLMCallResponse, honcho_llm_call
|
||||
from src.utils.formatting import utc_now_iso
|
||||
from src.utils.tokens import estimate_tokens, track_deriver_input_tokens
|
||||
|
||||
|
|
@ -78,6 +78,10 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
def _get_summary_model_config() -> ConfiguredModelSettings:
|
||||
return settings.SUMMARY.MODEL_CONFIG
|
||||
|
||||
|
||||
# Configuration constants for summaries
|
||||
MESSAGES_PER_SHORT_SUMMARY = settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY
|
||||
MESSAGES_PER_LONG_SUMMARY = settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY
|
||||
|
|
@ -212,7 +216,7 @@ async def create_short_summary(
|
|||
)
|
||||
|
||||
return await honcho_llm_call(
|
||||
llm_settings=settings.SUMMARY,
|
||||
model_config=_get_summary_model_config(),
|
||||
prompt=prompt,
|
||||
max_tokens=settings.SUMMARY.MAX_TOKENS_SHORT,
|
||||
)
|
||||
|
|
@ -237,7 +241,7 @@ async def create_long_summary(
|
|||
)
|
||||
|
||||
return await honcho_llm_call(
|
||||
llm_settings=settings.SUMMARY,
|
||||
model_config=_get_summary_model_config(),
|
||||
prompt=prompt,
|
||||
max_tokens=settings.SUMMARY.MAX_TOKENS_LONG,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ class GetOrCreateResult(Generic[T]):
|
|||
await self.on_commit()
|
||||
|
||||
|
||||
SupportedProviders = Literal["anthropic", "openai", "google", "groq", "custom", "vllm"]
|
||||
TaskType = Literal[
|
||||
"webhook", "summary", "representation", "dream", "deletion", "reconciler"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||
_VALID_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
|
||||
# Schema for LanceDB tables
|
||||
# id: string, vector: fixed_size_list of float32 (1536 dimensions for OpenAI embeddings)
|
||||
# id: string, vector: fixed_size_list of float32 (dimension from embedding settings)
|
||||
# Additional metadata columns are added dynamically
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false
|
||||
|
|
@ -93,7 +93,7 @@ class LanceDBVectorStore(VectorStore):
|
|||
fields: list[pa.Field] = [
|
||||
pa.field("id", pa.string()),
|
||||
pa.field(
|
||||
"vector", pa.list_(pa.float32(), settings.VECTOR_STORE.DIMENSIONS)
|
||||
"vector", pa.list_(pa.float32(), settings.EMBEDDING.VECTOR_DIMENSIONS)
|
||||
),
|
||||
]
|
||||
fields.extend(self._metadata_fields_for_namespace(namespace))
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
# Test package marker for shared helper imports.
|
||||
|
|
@ -648,29 +648,69 @@ sys.path.insert(0, str(project_root))
|
|||
# and will be inherited by this subprocess
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
from src.config import settings
|
||||
|
||||
# Function to recursively print settings
|
||||
SENSITIVE_TOKENS = ('password', 'secret', 'key', 'uri')
|
||||
|
||||
def _mask(full_key, value):
|
||||
if isinstance(full_key, str) and any(t in full_key.lower() for t in SENSITIVE_TOKENS):
|
||||
return '*' * len(value) if value else 'None'
|
||||
return value
|
||||
|
||||
def _compact(model):
|
||||
# Render a pydantic BaseModel as `field=value` pairs, skipping
|
||||
# None / empty-dict fields and recursing into nested models.
|
||||
parts = []
|
||||
for field_name in type(model).model_fields:
|
||||
val = getattr(model, field_name)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, BaseModel):
|
||||
inner = _compact(val)
|
||||
if inner:
|
||||
parts.append(f"{{field_name}}=({{inner}})")
|
||||
continue
|
||||
if isinstance(val, dict) and not val:
|
||||
continue
|
||||
parts.append(f"{{field_name}}={{val!r}}")
|
||||
return " ".join(parts)
|
||||
|
||||
def print_settings(obj, prefix="", max_depth=3, current_depth=0):
|
||||
if current_depth >= max_depth:
|
||||
return
|
||||
if hasattr(obj, '__dict__'):
|
||||
for key, value in obj.__dict__.items():
|
||||
if not key.startswith('_'):
|
||||
full_key = f"{{prefix}}.{{key}}" if prefix else key
|
||||
# Handle nested settings objects
|
||||
if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))):
|
||||
print(f"\\n📋 {{full_key}}:")
|
||||
print_settings(value, full_key, max_depth, current_depth + 1)
|
||||
else:
|
||||
# Mask sensitive information
|
||||
if isinstance(full_key, str) and any(sensitive in full_key.lower() for sensitive in ['password', 'secret', 'key', 'uri']):
|
||||
masked_value = '*' * len(value) if value else 'None'
|
||||
else:
|
||||
masked_value = value
|
||||
print(f" {{key}}: {{masked_value}}")
|
||||
if not hasattr(obj, '__dict__'):
|
||||
return
|
||||
for key, value in obj.__dict__.items():
|
||||
if key.startswith('_'):
|
||||
continue
|
||||
full_key = f"{{prefix}}.{{key}}" if prefix else key
|
||||
|
||||
# dict-of-BaseModel → print each entry on its own line compactly
|
||||
if (
|
||||
isinstance(value, dict) and value
|
||||
and all(isinstance(v, BaseModel) for v in value.values())
|
||||
):
|
||||
print(f"\\n📋 {{full_key}}:")
|
||||
for k, v in value.items():
|
||||
rendered = _compact(v)
|
||||
print(f" {{k}}: {{rendered}}")
|
||||
continue
|
||||
|
||||
if isinstance(value, BaseModel):
|
||||
print(f"\\n📋 {{full_key}}:")
|
||||
rendered = _compact(value)
|
||||
if rendered:
|
||||
print(f" {{rendered}}")
|
||||
continue
|
||||
|
||||
if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))):
|
||||
print(f"\\n📋 {{full_key}}:")
|
||||
print_settings(value, full_key, max_depth, current_depth + 1)
|
||||
continue
|
||||
|
||||
print(f" {{key}}: {{_mask(full_key, value)}}")
|
||||
|
||||
# Print all settings
|
||||
print_settings(settings)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -72,8 +72,17 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = (
|
|||
"tests/bench/",
|
||||
"tests/alembic/",
|
||||
"tests/unified/",
|
||||
"tests/live_llm/",
|
||||
# Pure llm unit tests should stay isolated from the broader app/runtime fixtures.
|
||||
"tests/llm/",
|
||||
# LLM transport tests mock providers directly and don't need database/runtime setup.
|
||||
"tests/utils/test_length_finish_reason.py",
|
||||
"tests/utils/test_clients.py",
|
||||
)
|
||||
|
||||
_LIVE_LLM_MARKER = "live_llm"
|
||||
_LIVE_LLM_SKIP_REASON = "live LLM tests are disabled; pass --live-llm to run them"
|
||||
|
||||
|
||||
def _requires_runtime_mocks(nodeid: str) -> bool:
|
||||
return not any(
|
||||
|
|
@ -87,6 +96,28 @@ def _get_nodeid(request: pytest.FixtureRequest) -> str:
|
|||
return nodeid if isinstance(nodeid, str) else ""
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption(
|
||||
"--live-llm",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run opt-in live LLM integration tests that call provider APIs.",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
config: pytest.Config,
|
||||
items: list[pytest.Item],
|
||||
) -> None:
|
||||
if config.getoption("--live-llm"):
|
||||
return
|
||||
|
||||
skip_live = pytest.mark.skip(reason=_LIVE_LLM_SKIP_REASON)
|
||||
for item in items:
|
||||
if _LIVE_LLM_MARKER in item.keywords:
|
||||
item.add_marker(skip_live)
|
||||
|
||||
|
||||
def _get_test_db_url(worker_id: str) -> URL:
|
||||
"""Get a worker-specific test database URL for pytest-xdist parallelism."""
|
||||
|
||||
|
|
@ -412,9 +443,10 @@ def _content_to_embedding(content: str) -> list[float]:
|
|||
|
||||
# Hash the content to get a deterministic seed
|
||||
content_hash = hashlib.sha256(content.encode()).digest()
|
||||
# Use hash bytes to generate 1536 floats between -1 and 1
|
||||
vector_dimensions = settings.EMBEDDING.VECTOR_DIMENSIONS
|
||||
# Use hash bytes to generate deterministic floats between -1 and 1
|
||||
embedding: list[float] = []
|
||||
for i in range(1536):
|
||||
for i in range(vector_dimensions):
|
||||
# Use different bytes from hash (cycling through)
|
||||
byte_val = content_hash[i % len(content_hash)]
|
||||
# Normalize to [-1, 1] range
|
||||
|
|
@ -431,6 +463,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest):
|
|||
|
||||
with (
|
||||
patch("src.embedding_client.embedding_client.embed") as mock_embed,
|
||||
patch(
|
||||
"src.embedding_client.embedding_client.simple_batch_embed"
|
||||
) as mock_simple_batch_embed,
|
||||
patch("src.embedding_client.embedding_client.batch_embed") as mock_batch_embed,
|
||||
):
|
||||
# Mock the embed method to return content-dependent embedding
|
||||
|
|
@ -439,6 +474,11 @@ def mock_openai_embeddings(request: pytest.FixtureRequest):
|
|||
|
||||
mock_embed.side_effect = embed_side_effect
|
||||
|
||||
async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]:
|
||||
return [_content_to_embedding(text) for text in texts]
|
||||
|
||||
mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func
|
||||
|
||||
# Mock the batch_embed method to return content-dependent embeddings
|
||||
async def mock_batch_embed_func(
|
||||
id_resource_dict: dict[str, tuple[str, list[int]]],
|
||||
|
|
@ -450,7 +490,11 @@ def mock_openai_embeddings(request: pytest.FixtureRequest):
|
|||
|
||||
mock_batch_embed.side_effect = mock_batch_embed_func
|
||||
|
||||
yield {"embed": mock_embed, "batch_embed": mock_batch_embed}
|
||||
yield {
|
||||
"embed": mock_embed,
|
||||
"simple_batch_embed": mock_simple_batch_embed,
|
||||
"batch_embed": mock_batch_embed,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -670,10 +714,10 @@ def mock_honcho_llm_call(request: pytest.FixtureRequest):
|
|||
# Patch the honcho_llm_call decorator to prevent actual LLM calls at module level
|
||||
original_decorator = None
|
||||
try:
|
||||
import src.utils.clients
|
||||
import src.llm
|
||||
|
||||
original_decorator = src.utils.clients.honcho_llm_call
|
||||
src.utils.clients.honcho_llm_call = lambda *args, **kwargs: lambda func: func # pyright: ignore[reportUnknownLambdaType]
|
||||
original_decorator = src.llm.honcho_llm_call
|
||||
src.llm.honcho_llm_call = lambda *args, **kwargs: lambda func: func # pyright: ignore[reportUnknownLambdaType]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
|
@ -707,21 +751,21 @@ def mock_honcho_llm_call(request: pytest.FixtureRequest):
|
|||
|
||||
return mock_llm_decorator
|
||||
|
||||
with patch("src.utils.clients.honcho_llm_call", side_effect=decorator_factory):
|
||||
with patch("src.llm.honcho_llm_call", side_effect=decorator_factory):
|
||||
yield decorator_factory
|
||||
|
||||
# Restore the original decorator
|
||||
if original_decorator:
|
||||
try:
|
||||
import src.utils.clients
|
||||
import src.llm
|
||||
|
||||
src.utils.clients.honcho_llm_call = original_decorator
|
||||
src.llm.honcho_llm_call = original_decorator
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest):
|
||||
def mock_tracked_db(request: pytest.FixtureRequest):
|
||||
"""Mock tracked_db to create fresh sessions per call.
|
||||
|
||||
Using a session factory instead of a shared session avoids asyncio lock
|
||||
|
|
@ -733,6 +777,7 @@ def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest):
|
|||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
db_engine = request.getfixturevalue("db_engine")
|
||||
session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False)
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import signal
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import models
|
||||
from src.utils.representation import Representation
|
||||
from src.config import settings
|
||||
from src.deriver.deriver import process_representation_tasks_batch
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
from src.utils.representation import PromptRepresentation, Representation
|
||||
from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key
|
||||
|
||||
|
||||
|
|
@ -12,6 +17,59 @@ from src.utils.work_unit import construct_work_unit_key, parse_work_unit_key
|
|||
class TestDeriverProcessing:
|
||||
"""Test suite for deriver processing using the conftest fixtures"""
|
||||
|
||||
async def test_process_representation_tasks_batch_uses_model_config(self):
|
||||
message = Mock(
|
||||
id=1,
|
||||
public_id="msg_1",
|
||||
session_name="session-1",
|
||||
workspace_name="workspace-1",
|
||||
peer_name="alice",
|
||||
content="hello",
|
||||
token_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
configuration = Mock()
|
||||
configuration.reasoning.enabled = True
|
||||
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content=PromptRepresentation(explicit=[]),
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["STOP"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.deriver.deriver.honcho_llm_call",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_llm_call:
|
||||
await process_representation_tasks_batch(
|
||||
messages=[message],
|
||||
message_level_configuration=configuration,
|
||||
observers=["bob"],
|
||||
observed="alice",
|
||||
queue_item_message_ids=[1],
|
||||
)
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected deriver LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.DERIVER.MODEL_CONFIG.model_copy(
|
||||
update={
|
||||
"stop_sequences": [" \n", "\n\n\n\n"],
|
||||
}
|
||||
)
|
||||
assert "model_config" in kwargs
|
||||
assert kwargs["model_config"].model == expected_config.model
|
||||
assert kwargs["model_config"].thinking_effort == expected_config.thinking_effort
|
||||
assert (
|
||||
kwargs["model_config"].thinking_budget_tokens
|
||||
== expected_config.thinking_budget_tokens
|
||||
)
|
||||
assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences
|
||||
assert "llm_settings" not in kwargs
|
||||
|
||||
async def test_work_unit_key_generation(
|
||||
self,
|
||||
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
||||
|
|
|
|||
|
|
@ -1088,8 +1088,16 @@ class TestQueueProcessing:
|
|||
db_session: AsyncSession,
|
||||
sample_session_with_peers: tuple[models.Session, list[models.Peer]],
|
||||
create_queue_payload: Callable[..., Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test that representation work units below token threshold are not claimed"""
|
||||
"""Test that representation work units below token threshold are not claimed.
|
||||
|
||||
The token-threshold gate in QueueManager.get_and_claim_work_units is
|
||||
skipped entirely when DERIVER_FLUSH_ENABLED is True, so this test
|
||||
forces it False regardless of what the process env has set (benches
|
||||
commonly enable flush mode for immediate processing).
|
||||
"""
|
||||
monkeypatch.setattr(settings.DERIVER, "FLUSH_ENABLED", False)
|
||||
|
||||
session, peers = sample_session_with_peers
|
||||
peer = peers[0]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import settings
|
||||
from src.dialectic.core import DialecticAgent
|
||||
from src.llm import (
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
StreamingResponseWithMetadata,
|
||||
)
|
||||
|
||||
|
||||
async def _stream_chunks() -> StreamingResponseWithMetadata:
|
||||
async def _stream():
|
||||
yield HonchoLLMCallStreamChunk(content="streamed")
|
||||
yield HonchoLLMCallStreamChunk(content="", is_done=True)
|
||||
|
||||
return StreamingResponseWithMetadata(
|
||||
_stream(),
|
||||
tool_calls_made=[],
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
iterations=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialectic_answer_uses_level_model_config() -> None:
|
||||
agent = DialecticAgent(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
observer="observer",
|
||||
observed="observed",
|
||||
reasoning_level="medium",
|
||||
)
|
||||
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content="answer",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["stop"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
DialecticAgent,
|
||||
"_prepare_query",
|
||||
new=AsyncMock(
|
||||
return_value=(AsyncMock(), "task", "run", time.perf_counter())
|
||||
),
|
||||
),
|
||||
patch.object(DialecticAgent, "_log_response_metrics"),
|
||||
patch(
|
||||
"src.dialectic.core.honcho_llm_call",
|
||||
new=AsyncMock(return_value=mock_response),
|
||||
) as mock_llm_call,
|
||||
):
|
||||
result = await agent.answer("What do you know?")
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected dialectic LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.DIALECTIC.LEVELS["medium"].MODEL_CONFIG
|
||||
|
||||
assert result == "answer"
|
||||
assert kwargs["model_config"] == expected_config
|
||||
assert "llm_settings" not in kwargs
|
||||
assert "thinking_budget_tokens" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialectic_answer_stream_uses_level_model_config() -> None:
|
||||
agent = DialecticAgent(
|
||||
workspace_name="workspace",
|
||||
session_name="session",
|
||||
observer="observer",
|
||||
observed="observed",
|
||||
reasoning_level="medium",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
DialecticAgent,
|
||||
"_prepare_query",
|
||||
new=AsyncMock(
|
||||
return_value=(AsyncMock(), "task", "run", time.perf_counter())
|
||||
),
|
||||
),
|
||||
patch.object(DialecticAgent, "_log_response_metrics"),
|
||||
patch(
|
||||
"src.dialectic.core.honcho_llm_call",
|
||||
new=AsyncMock(return_value=await _stream_chunks()),
|
||||
) as mock_llm_call,
|
||||
):
|
||||
chunks = [chunk async for chunk in agent.answer_stream("What do you know?")]
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected dialectic streaming LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.DIALECTIC.LEVELS["medium"].MODEL_CONFIG
|
||||
|
||||
assert chunks == ["streamed"]
|
||||
assert kwargs["model_config"] == expected_config
|
||||
assert "llm_settings" not in kwargs
|
||||
assert "thinking_budget_tokens" not in kwargs
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import settings
|
||||
from src.dreamer.specialists import DeductionSpecialist
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deduction_specialist_uses_nested_model_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings.METRICS, "ENABLED", False)
|
||||
specialist = DeductionSpecialist()
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content="done",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["stop"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.dreamer.specialists.crud.get_peer",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
"src.dreamer.specialists.crud.get_peer_card",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"src.dreamer.specialists.create_tool_executor",
|
||||
new=AsyncMock(return_value=AsyncMock()),
|
||||
),
|
||||
patch(
|
||||
"src.dreamer.specialists.honcho_llm_call",
|
||||
new=AsyncMock(return_value=mock_response),
|
||||
) as mock_llm_call,
|
||||
):
|
||||
result = await specialist.run(
|
||||
workspace_name="workspace",
|
||||
observer="alice",
|
||||
observed="alice",
|
||||
session_name="session",
|
||||
)
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected dreamer LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.DREAM.DEDUCTION_MODEL_CONFIG
|
||||
|
||||
assert result.content == "done"
|
||||
assert kwargs["model_config"] == expected_config
|
||||
assert "llm_settings" not in kwargs
|
||||
|
|
@ -641,68 +641,6 @@ class TestEnqueueFunction:
|
|||
assert observer_who_stayed.name in observers
|
||||
assert sender_peer.name in observers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_not_in_peer_configuration_uses_defaults(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Test get_effective_observe_me handles missing sender configuration gracefully"""
|
||||
test_workspace, existing_peer = sample_data
|
||||
|
||||
# Create observer peer
|
||||
observer_peer = models.Peer(
|
||||
workspace_name=test_workspace.name, name=str(generate_nanoid())
|
||||
)
|
||||
db_session.add(observer_peer)
|
||||
|
||||
# Create session with only observer (sender not in peers_with_configuration)
|
||||
test_session = (
|
||||
await crud.get_or_create_session(
|
||||
db_session,
|
||||
schemas.SessionCreate(
|
||||
name=str(generate_nanoid()),
|
||||
peers={
|
||||
observer_peer.name: schemas.SessionPeerConfig(
|
||||
observe_others=True
|
||||
),
|
||||
},
|
||||
),
|
||||
test_workspace.name,
|
||||
)
|
||||
).resource
|
||||
await db_session.commit()
|
||||
|
||||
# Create message from peer NOT in the session configuration
|
||||
# This simulates the race condition where a peer left after sending
|
||||
payload = await self.create_sample_payload(
|
||||
db_session,
|
||||
workspace_name=test_workspace.name,
|
||||
session_name=test_session.name,
|
||||
peer_name=existing_peer.name,
|
||||
)
|
||||
|
||||
initial_count = await self.count_queue_items(db_session)
|
||||
await enqueue(payload)
|
||||
final_count = await self.count_queue_items(db_session)
|
||||
|
||||
# With deduplication: 1 queue item per message with all observers
|
||||
assert final_count - initial_count == 1
|
||||
|
||||
result = await db_session.execute(
|
||||
select(QueueItem).where(QueueItem.session_id == test_session.id)
|
||||
)
|
||||
queue_items = result.scalars().all()
|
||||
|
||||
assert len(queue_items) == 1
|
||||
item = queue_items[0]
|
||||
assert item.payload.get("task_type") == "representation"
|
||||
assert item.payload.get("observed") == existing_peer.name
|
||||
observers = item.payload.get("observers")
|
||||
assert observers is not None
|
||||
assert existing_peer.name in observers # self-observation (default)
|
||||
assert observer_peer.name in observers # observer (observing others)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_active_inactive_peers_complex_scenario(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ async def test_message_chunking_creates_multiple_embeddings(
|
|||
monkeypatch.setattr("src.config.settings.EMBED_MESSAGES", True)
|
||||
|
||||
# Mock a low token limit to force chunking
|
||||
monkeypatch.setattr("src.config.settings.MAX_EMBEDDING_TOKENS", 10)
|
||||
monkeypatch.setattr("src.config.settings.EMBEDDING.MAX_INPUT_TOKENS", 10)
|
||||
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from prometheus_client import Counter
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models, schemas
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
from src.models import Peer, Workspace
|
||||
from src.schemas import (
|
||||
ResolvedConfiguration,
|
||||
|
|
@ -31,7 +32,6 @@ from src.telemetry.prometheus.metrics import (
|
|||
deriver_tokens_processed_counter,
|
||||
dialectic_tokens_processed_counter,
|
||||
)
|
||||
from src.utils.clients import HonchoLLMCallResponse
|
||||
from src.utils.representation import ExplicitObservationBase, PromptRepresentation
|
||||
from src.utils.summarizer import (
|
||||
SummaryType,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
# Live LLM Tests
|
||||
|
||||
These tests call real provider APIs and are disabled by default.
|
||||
|
||||
Run them with:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/live_llm -n 0 --live-llm --no-header -q
|
||||
```
|
||||
|
||||
Required API key env vars:
|
||||
|
||||
- `LLM_ANTHROPIC_API_KEY`
|
||||
- `LLM_OPENAI_API_KEY`
|
||||
- `LLM_GEMINI_API_KEY`
|
||||
|
||||
Model-family env vars:
|
||||
|
||||
- `LIVE_LLM_ANTHROPIC_45_PLUS_MODELS`
|
||||
- `LIVE_LLM_OPENAI_GPT4_MODELS`
|
||||
- `LIVE_LLM_OPENAI_GPT5_MODELS`
|
||||
- `LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS` (OpenAI-transport → OpenRouter-served non-reasoning models)
|
||||
- `LIVE_LLM_GEMINI_25_MODELS`
|
||||
- `LIVE_LLM_GEMINI_30_MODELS`
|
||||
- `LIVE_LLM_GEMINI_31_MODELS`
|
||||
|
||||
Each model env var accepts a comma-separated list of bare model ids or provider-qualified ids.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
export LIVE_LLM_ANTHROPIC_45_PLUS_MODELS="claude-sonnet-4-5,claude-sonnet-4-6"
|
||||
export LIVE_LLM_OPENAI_GPT4_MODELS="gpt-4.1"
|
||||
export LIVE_LLM_OPENAI_GPT5_MODELS="gpt-5,gpt-5.4,gpt-5.4-mini"
|
||||
export LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS="inception/mercury-2"
|
||||
export LIVE_LLM_GEMINI_25_MODELS="gemini-2.5-flash,gemini-2.5-pro"
|
||||
export LIVE_LLM_GEMINI_30_MODELS="gemini-3-flash-preview"
|
||||
export LIVE_LLM_GEMINI_31_MODELS="gemini-3.1-pro-preview"
|
||||
```
|
||||
|
||||
OpenRouter-routed models require additional env for the proxy endpoint:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY="sk-or-v1-..."
|
||||
# Per-feature config example:
|
||||
# DERIVER_MODEL_CONFIG__TRANSPORT=openai
|
||||
# DERIVER_MODEL_CONFIG__MODEL=inception/mercury-2
|
||||
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
|
||||
# DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
Coverage by provider:
|
||||
|
||||
- Anthropic: structured output path, prompt caching metrics, thinking blocks, multi-turn tool replay
|
||||
- OpenAI GPT-4 class: structured outputs, prompt caching
|
||||
- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing
|
||||
- OpenAI transport → OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers
|
||||
- Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay
|
||||
- Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Live LLM integration test package.
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import ModelConfig, settings
|
||||
from src.llm import get_backend
|
||||
from src.llm.caching import gemini_cache_store
|
||||
|
||||
from .model_matrix import LiveModelSpec, selected_model_summary_lines
|
||||
|
||||
|
||||
class StructuredLiveResponse(BaseModel):
|
||||
provider: str
|
||||
family: str
|
||||
answer: str
|
||||
|
||||
|
||||
def pytest_report_header(config: pytest.Config) -> list[str] | None:
|
||||
if not config.getoption("--live-llm"):
|
||||
return None
|
||||
return ["live llm model matrix:"] + [
|
||||
f" {line}" for line in selected_model_summary_lines()
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_live_gemini_cache_store() -> Iterator[None]:
|
||||
# The live Gemini cache store is process-local and should not leak state between tests.
|
||||
gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage]
|
||||
yield
|
||||
gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def require_provider_key(model_spec: LiveModelSpec) -> None:
|
||||
key_present = {
|
||||
"anthropic": bool(settings.LLM.ANTHROPIC_API_KEY),
|
||||
"openai": bool(settings.LLM.OPENAI_API_KEY),
|
||||
"gemini": bool(settings.LLM.GEMINI_API_KEY),
|
||||
}[model_spec.provider]
|
||||
if not key_present:
|
||||
pytest.skip(f"Missing API key for live provider {model_spec.provider}")
|
||||
|
||||
|
||||
def make_model_config(model_spec: LiveModelSpec, **overrides: Any) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
model=model_spec.model,
|
||||
transport=model_spec.provider,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def make_backend(
|
||||
model_spec: LiveModelSpec, **config_overrides: Any
|
||||
) -> tuple[Any, ModelConfig]:
|
||||
config = make_model_config(model_spec, **config_overrides)
|
||||
return get_backend(config), config
|
||||
|
||||
|
||||
def make_large_system_prompt(*, label: str) -> str:
|
||||
repeated_prefix = " ".join([f"{label}-token-{index % 37}" for index in range(2400)])
|
||||
return (
|
||||
f"{label} system prompt. Reuse this prefix exactly for prompt-caching validation. "
|
||||
f"{repeated_prefix}"
|
||||
)
|
||||
|
||||
|
||||
def favorite_prime_tools() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "get_favorite_prime",
|
||||
"description": "Return the favorite prime number for the current test run.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Why the caller wants the prime number.",
|
||||
}
|
||||
},
|
||||
"required": ["topic"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def execute_local_tool(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
assert tool_name == "get_favorite_prime"
|
||||
assert isinstance(tool_input, dict)
|
||||
return "13"
|
||||
|
||||
|
||||
def wrap_async_method(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Any,
|
||||
attribute: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
original = getattr(target, attribute)
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
calls.append({"args": args, "kwargs": kwargs})
|
||||
return await original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(target, attribute, wrapped)
|
||||
return calls
|
||||
|
||||
|
||||
def extract_openai_reasoning_tokens(raw_response: Any) -> int | None:
|
||||
usage = getattr(raw_response, "usage", None)
|
||||
if usage is None:
|
||||
return None
|
||||
details = getattr(usage, "completion_tokens_details", None)
|
||||
if details is None:
|
||||
return None
|
||||
reasoning_tokens = getattr(details, "reasoning_tokens", None)
|
||||
return int(reasoning_tokens) if reasoning_tokens is not None else None
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
ProviderName = Literal["anthropic", "openai", "gemini"]
|
||||
FeatureName = Literal["thinking", "structured_output", "caching", "reasoning"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveModelFamily:
|
||||
provider: ProviderName
|
||||
family: str
|
||||
env_var: str
|
||||
default_models: tuple[str, ...] = ()
|
||||
supports_thinking: bool = False
|
||||
supports_structured_output: bool = False
|
||||
supports_caching: bool = False
|
||||
supports_reasoning: bool = False
|
||||
supports_tool_replay: bool = False
|
||||
docs_url: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveModelSpec:
|
||||
provider: ProviderName
|
||||
family: str
|
||||
model: str
|
||||
env_var: str
|
||||
supports_thinking: bool
|
||||
supports_structured_output: bool
|
||||
supports_caching: bool
|
||||
supports_reasoning: bool
|
||||
supports_tool_replay: bool
|
||||
docs_url: str | None = None
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return f"{self.provider}:{self.family}:{self.model}"
|
||||
|
||||
|
||||
MODEL_FAMILIES: tuple[LiveModelFamily, ...] = (
|
||||
LiveModelFamily(
|
||||
provider="anthropic",
|
||||
family="claude_4_5_plus",
|
||||
env_var="LIVE_LLM_ANTHROPIC_45_PLUS_MODELS",
|
||||
supports_thinking=True,
|
||||
supports_structured_output=True,
|
||||
supports_caching=True,
|
||||
supports_tool_replay=True,
|
||||
docs_url="https://docs.anthropic.com/en/docs/about-claude/models/all-models",
|
||||
),
|
||||
LiveModelFamily(
|
||||
provider="openai",
|
||||
family="gpt_4_class",
|
||||
env_var="LIVE_LLM_OPENAI_GPT4_MODELS",
|
||||
default_models=("gpt-4.1",),
|
||||
supports_structured_output=True,
|
||||
supports_caching=True,
|
||||
docs_url="https://platform.openai.com/docs/models/gpt-4.1",
|
||||
),
|
||||
LiveModelFamily(
|
||||
provider="openai",
|
||||
family="gpt_5_class",
|
||||
env_var="LIVE_LLM_OPENAI_GPT5_MODELS",
|
||||
default_models=("gpt-5", "gpt-5.4", "gpt-5.4-mini"),
|
||||
supports_structured_output=True,
|
||||
supports_caching=True,
|
||||
supports_reasoning=True,
|
||||
docs_url="https://platform.openai.com/docs/models/gpt-5",
|
||||
),
|
||||
# OpenAI-compatible transport → OpenRouter-served non-reasoning models.
|
||||
# Best canary for operators routing exotic providers through OpenRouter:
|
||||
# if honcho works here, it works for most OR-served models. Currently
|
||||
# anchored on Inception Labs' Mercury-2 diffusion model (non-chat
|
||||
# architecture, must stay on max_tokens, no reasoning_effort).
|
||||
LiveModelFamily(
|
||||
provider="openai",
|
||||
family="openrouter_non_reasoning",
|
||||
env_var="LIVE_LLM_OPENAI_OPENROUTER_NON_REASONING_MODELS",
|
||||
default_models=("inception/mercury-2",),
|
||||
supports_structured_output=False,
|
||||
supports_caching=False,
|
||||
docs_url="https://openrouter.ai/models",
|
||||
),
|
||||
LiveModelFamily(
|
||||
provider="gemini",
|
||||
family="gemini_2_5_class",
|
||||
env_var="LIVE_LLM_GEMINI_25_MODELS",
|
||||
default_models=("gemini-2.5-flash",),
|
||||
supports_thinking=True,
|
||||
supports_structured_output=True,
|
||||
supports_caching=True,
|
||||
supports_tool_replay=True,
|
||||
docs_url="https://ai.google.dev/gemini-api/docs/models/gemini",
|
||||
),
|
||||
LiveModelFamily(
|
||||
provider="gemini",
|
||||
family="gemini_3_0_class",
|
||||
env_var="LIVE_LLM_GEMINI_30_MODELS",
|
||||
supports_thinking=True,
|
||||
supports_structured_output=True,
|
||||
supports_caching=True,
|
||||
supports_tool_replay=True,
|
||||
docs_url="https://ai.google.dev/gemini-api/docs/models/gemini",
|
||||
),
|
||||
LiveModelFamily(
|
||||
provider="gemini",
|
||||
family="gemini_3_1_class",
|
||||
env_var="LIVE_LLM_GEMINI_31_MODELS",
|
||||
supports_thinking=True,
|
||||
supports_structured_output=False,
|
||||
supports_caching=False,
|
||||
supports_tool_replay=True,
|
||||
docs_url="https://ai.google.dev/gemini-api/docs/models/gemini",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_env_models(value: str | None) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
models = [model.strip() for model in value.split(",")]
|
||||
return tuple(model for model in models if model)
|
||||
|
||||
|
||||
def iter_live_model_specs() -> tuple[LiveModelSpec, ...]:
|
||||
specs: list[LiveModelSpec] = []
|
||||
for family in MODEL_FAMILIES:
|
||||
configured_models = _parse_env_models(os.getenv(family.env_var))
|
||||
models = configured_models or family.default_models
|
||||
for model in models:
|
||||
specs.append(
|
||||
LiveModelSpec(
|
||||
provider=family.provider,
|
||||
family=family.family,
|
||||
model=model,
|
||||
env_var=family.env_var,
|
||||
supports_thinking=family.supports_thinking,
|
||||
supports_structured_output=family.supports_structured_output,
|
||||
supports_caching=family.supports_caching,
|
||||
supports_reasoning=family.supports_reasoning,
|
||||
supports_tool_replay=family.supports_tool_replay,
|
||||
docs_url=family.docs_url,
|
||||
)
|
||||
)
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
def get_live_model_specs(
|
||||
*,
|
||||
provider: ProviderName | None = None,
|
||||
feature: FeatureName | None = None,
|
||||
) -> tuple[LiveModelSpec, ...]:
|
||||
specs = iter_live_model_specs()
|
||||
filtered: list[LiveModelSpec] = []
|
||||
|
||||
for spec in specs:
|
||||
if provider is not None and spec.provider != provider:
|
||||
continue
|
||||
if feature == "thinking" and not spec.supports_thinking:
|
||||
continue
|
||||
if feature == "structured_output" and not spec.supports_structured_output:
|
||||
continue
|
||||
if feature == "caching" and not spec.supports_caching:
|
||||
continue
|
||||
if feature == "reasoning" and not spec.supports_reasoning:
|
||||
continue
|
||||
filtered.append(spec)
|
||||
|
||||
return tuple(filtered)
|
||||
|
||||
|
||||
def selected_model_summary_lines() -> list[str]:
|
||||
lines: list[str] = []
|
||||
for family in MODEL_FAMILIES:
|
||||
configured_models = _parse_env_models(os.getenv(family.env_var))
|
||||
models = configured_models or family.default_models
|
||||
joined_models = ", ".join(models) if models else "(none configured)"
|
||||
lines.append(
|
||||
f"{family.env_var} [{family.provider}/{family.family}]: {joined_models}"
|
||||
)
|
||||
return lines
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.backend import CompletionResult
|
||||
from src.llm.history_adapters import AnthropicHistoryAdapter
|
||||
from src.llm.request_builder import execute_completion
|
||||
|
||||
from .conftest import (
|
||||
StructuredLiveResponse,
|
||||
execute_local_tool,
|
||||
favorite_prime_tools,
|
||||
make_backend,
|
||||
make_large_system_prompt,
|
||||
require_provider_key,
|
||||
wrap_async_method,
|
||||
)
|
||||
from .model_matrix import LiveModelSpec, get_live_model_specs
|
||||
|
||||
pytestmark = [pytest.mark.live_llm, pytest.mark.requires_anthropic]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="anthropic"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_anthropic_structured_output_and_prefix_caching(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": make_large_system_prompt(label=f"anthropic-{model_spec.family}"),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Return valid JSON with provider='anthropic', "
|
||||
f"family='{model_spec.family}', and answer='cache-ok'."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
results: list[CompletionResult] = []
|
||||
for _ in range(3):
|
||||
results.append(
|
||||
await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=256,
|
||||
response_format=StructuredLiveResponse,
|
||||
)
|
||||
)
|
||||
if len(results) >= 2 and results[-1].cache_read_input_tokens > 0:
|
||||
break
|
||||
|
||||
first = results[0]
|
||||
later_results = results[1:]
|
||||
|
||||
assert isinstance(first.content, StructuredLiveResponse)
|
||||
assert first.content.provider == "anthropic"
|
||||
assert first.content.family == model_spec.family
|
||||
assert later_results, "Anthropic caching validation requires at least two calls"
|
||||
for result in later_results:
|
||||
assert isinstance(result.content, StructuredLiveResponse)
|
||||
assert any(
|
||||
result.cache_read_input_tokens > 0 for result in later_results
|
||||
), "Anthropic prompt caching did not report a cache hit after repeated identical requests"
|
||||
|
||||
assert len(create_calls) == len(results)
|
||||
for call in create_calls:
|
||||
assert call["kwargs"]["system"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="anthropic"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_anthropic_thinking_and_tool_replay(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec, thinking_budget_tokens=1024)
|
||||
create_calls = wrap_async_method(monkeypatch, backend._client.messages, "create")
|
||||
tools = favorite_prime_tools()
|
||||
adapter = AnthropicHistoryAdapter()
|
||||
|
||||
initial_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Before answering, call the get_favorite_prime tool exactly once. "
|
||||
"After you receive the tool result, answer in one sentence that includes "
|
||||
"the number and the word 'prime'."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=initial_messages,
|
||||
max_tokens=2048,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
assert create_calls[0]["kwargs"]["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1024,
|
||||
}
|
||||
assert first.tool_calls, "Anthropic should issue a tool call in the first turn"
|
||||
assert first.thinking_blocks, "Anthropic thinking blocks should be preserved"
|
||||
|
||||
tool_call = first.tool_calls[0]
|
||||
tool_result = execute_local_tool(tool_call.name, tool_call.input)
|
||||
replay_messages = initial_messages + [
|
||||
adapter.format_assistant_tool_message(first),
|
||||
*adapter.format_tool_results(
|
||||
[
|
||||
{
|
||||
"tool_id": tool_call.id,
|
||||
"tool_name": tool_call.name,
|
||||
"result": tool_result,
|
||||
}
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
second = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=replay_messages,
|
||||
max_tokens=2048,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
assert create_calls[1]["kwargs"]["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1024,
|
||||
}
|
||||
assert isinstance(second.content, str)
|
||||
assert "13" in second.content
|
||||
assert "prime" in second.content.lower()
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.caching import PromptCachePolicy
|
||||
from src.llm.history_adapters import GeminiHistoryAdapter
|
||||
from src.llm.request_builder import execute_completion
|
||||
|
||||
from .conftest import (
|
||||
StructuredLiveResponse,
|
||||
execute_local_tool,
|
||||
favorite_prime_tools,
|
||||
make_backend,
|
||||
make_large_system_prompt,
|
||||
require_provider_key,
|
||||
wrap_async_method,
|
||||
)
|
||||
from .model_matrix import LiveModelSpec, get_live_model_specs
|
||||
|
||||
pytestmark = [pytest.mark.live_llm, pytest.mark.requires_gemini]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="gemini", feature="structured_output"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_gemini_structured_output_and_explicit_cache_reuse(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec, temperature=0)
|
||||
cache_create_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.aio.caches,
|
||||
"create",
|
||||
)
|
||||
generate_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.aio.models,
|
||||
"generate_content",
|
||||
)
|
||||
cache_policy = PromptCachePolicy(mode="gemini_cached_content", ttl_seconds=300)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": make_large_system_prompt(label=f"gemini-{model_spec.family}"),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Return valid JSON with provider='gemini', "
|
||||
f"family='{model_spec.family}', and answer='cache-ok'. "
|
||||
"Return JSON only, with no prose or markdown."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=512,
|
||||
response_format=StructuredLiveResponse,
|
||||
cache_policy=cache_policy,
|
||||
)
|
||||
second = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=512,
|
||||
response_format=StructuredLiveResponse,
|
||||
cache_policy=cache_policy,
|
||||
)
|
||||
|
||||
assert isinstance(first.content, StructuredLiveResponse)
|
||||
assert first.content.provider == "gemini"
|
||||
assert first.content.family == model_spec.family
|
||||
assert isinstance(second.content, StructuredLiveResponse)
|
||||
|
||||
assert len(cache_create_calls) == 1
|
||||
assert len(generate_calls) == 2
|
||||
first_cached_content = generate_calls[0]["kwargs"]["config"]["cached_content"]
|
||||
second_cached_content = generate_calls[1]["kwargs"]["config"]["cached_content"]
|
||||
assert first_cached_content == second_cached_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model_spec",
|
||||
get_live_model_specs(provider="gemini", feature="thinking"),
|
||||
ids=lambda spec: spec.id,
|
||||
)
|
||||
async def test_live_gemini_thinking_and_tool_replay(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(
|
||||
model_spec,
|
||||
thinking_budget_tokens=512,
|
||||
temperature=0,
|
||||
)
|
||||
generate_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.aio.models,
|
||||
"generate_content",
|
||||
)
|
||||
tools = favorite_prime_tools()
|
||||
adapter = GeminiHistoryAdapter()
|
||||
|
||||
initial_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Before answering, call the get_favorite_prime tool exactly once. "
|
||||
"Do not answer with plain text on this turn. "
|
||||
"After the tool result arrives, answer with the exact text "
|
||||
"'13 is prime.'"
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=initial_messages,
|
||||
max_tokens=512,
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
assert generate_calls[0]["kwargs"]["config"]["thinking_config"] == {
|
||||
"thinking_budget": 512,
|
||||
}
|
||||
assert first.tool_calls, "Gemini should issue a tool call in the first turn"
|
||||
assert any(
|
||||
tool_call.thought_signature for tool_call in first.tool_calls
|
||||
), "Gemini tool replay should preserve thought signatures"
|
||||
|
||||
tool_call = first.tool_calls[0]
|
||||
tool_result = execute_local_tool(tool_call.name, tool_call.input)
|
||||
replay_messages = initial_messages + [
|
||||
adapter.format_assistant_tool_message(first),
|
||||
*adapter.format_tool_results(
|
||||
[
|
||||
{
|
||||
"tool_id": tool_call.id,
|
||||
"tool_name": tool_call.name,
|
||||
"result": tool_result,
|
||||
}
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
second = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=replay_messages,
|
||||
max_tokens=512,
|
||||
tools=tools,
|
||||
tool_choice="none",
|
||||
)
|
||||
|
||||
assert generate_calls[1]["kwargs"]["config"]["thinking_config"] == {
|
||||
"thinking_budget": 512,
|
||||
}
|
||||
assert isinstance(second.content, str)
|
||||
assert "13" in second.content
|
||||
assert "prime" in second.content.lower()
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.request_builder import execute_completion
|
||||
|
||||
from .conftest import (
|
||||
StructuredLiveResponse,
|
||||
make_backend,
|
||||
make_large_system_prompt,
|
||||
require_provider_key,
|
||||
wrap_async_method,
|
||||
)
|
||||
from .model_matrix import LiveModelSpec, get_live_model_specs
|
||||
|
||||
pytestmark = [pytest.mark.live_llm, pytest.mark.requires_openai]
|
||||
|
||||
_GPT4_SPECS = tuple(
|
||||
spec
|
||||
for spec in get_live_model_specs(provider="openai")
|
||||
if spec.family == "gpt_4_class"
|
||||
)
|
||||
_GPT5_SPECS = tuple(
|
||||
spec
|
||||
for spec in get_live_model_specs(provider="openai")
|
||||
if spec.family == "gpt_5_class"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_spec", _GPT4_SPECS, ids=lambda spec: spec.id)
|
||||
async def test_live_openai_gpt4_structured_output_and_prefix_caching(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec)
|
||||
parse_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.chat.completions,
|
||||
"parse",
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": make_large_system_prompt(label=f"openai-{model_spec.family}"),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Return valid JSON with provider='openai', "
|
||||
f"family='{model_spec.family}', and answer='cache-ok'."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=256,
|
||||
response_format=StructuredLiveResponse,
|
||||
)
|
||||
second = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=256,
|
||||
response_format=StructuredLiveResponse,
|
||||
)
|
||||
|
||||
assert isinstance(first.content, StructuredLiveResponse)
|
||||
assert first.content.provider == "openai"
|
||||
assert first.content.family == model_spec.family
|
||||
assert isinstance(second.content, StructuredLiveResponse)
|
||||
assert second.cache_read_input_tokens > 0
|
||||
|
||||
assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse
|
||||
assert "max_tokens" in parse_calls[0]["kwargs"]
|
||||
assert "max_completion_tokens" not in parse_calls[0]["kwargs"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_spec", _GPT5_SPECS, ids=lambda spec: spec.id)
|
||||
async def test_live_openai_gpt5_reasoning_structured_output_and_prefix_caching(
|
||||
model_spec: LiveModelSpec,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
require_provider_key(model_spec)
|
||||
backend, config = make_backend(model_spec, reasoning_effort="minimal")
|
||||
parse_calls = wrap_async_method(
|
||||
monkeypatch,
|
||||
backend._client.chat.completions,
|
||||
"parse",
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": make_large_system_prompt(label=f"openai-{model_spec.family}"),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Return valid JSON with provider='openai', "
|
||||
f"family='{model_spec.family}', and answer='reasoning-ok'."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
first = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=1024,
|
||||
response_format=StructuredLiveResponse,
|
||||
)
|
||||
second = await execute_completion(
|
||||
backend,
|
||||
config,
|
||||
messages=messages,
|
||||
max_tokens=1024,
|
||||
response_format=StructuredLiveResponse,
|
||||
)
|
||||
|
||||
assert isinstance(first.content, StructuredLiveResponse)
|
||||
assert first.content.provider == "openai"
|
||||
assert first.content.family == model_spec.family
|
||||
assert isinstance(second.content, StructuredLiveResponse)
|
||||
assert second.cache_read_input_tokens > 0
|
||||
|
||||
assert parse_calls[0]["kwargs"]["response_format"] is StructuredLiveResponse
|
||||
assert parse_calls[0]["kwargs"]["reasoning_effort"] == "minimal"
|
||||
assert "max_completion_tokens" in parse_calls[0]["kwargs"]
|
||||
assert "max_tokens" not in parse_calls[0]["kwargs"]
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.backend import CompletionResult, ProviderBackend, StreamChunk
|
||||
|
||||
|
||||
class FakeBackend(ProviderBackend):
|
||||
"""Simple backend for request-builder and orchestration tests."""
|
||||
|
||||
def __init__(self, responses: list[CompletionResult] | None = None) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self._responses: Iterator[CompletionResult] = iter(
|
||||
responses or [CompletionResult(content="ok")]
|
||||
)
|
||||
|
||||
async def complete(self, **kwargs: Any) -> CompletionResult:
|
||||
self.calls.append(kwargs)
|
||||
return next(self._responses)
|
||||
|
||||
async def stream(self, **kwargs: Any) -> AsyncIterator[StreamChunk]:
|
||||
self.calls.append(kwargs)
|
||||
result = next(self._responses)
|
||||
yield StreamChunk(content=result.content, is_done=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_backend() -> FakeBackend:
|
||||
return FakeBackend()
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
from typing import Any, cast
|
||||
|
||||
from src.utils.agent_tools import (
|
||||
DEDUCTION_SPECIALIST_TOOLS,
|
||||
INDUCTION_SPECIALIST_TOOLS,
|
||||
TOOLS,
|
||||
)
|
||||
|
||||
|
||||
def _observation_items_schema(tool_key: str) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
TOOLS[tool_key]["input_schema"]["properties"]["observations"]["items"],
|
||||
)
|
||||
|
||||
|
||||
def test_generic_create_observations_schema_has_level_specific_requirements() -> None:
|
||||
items = _observation_items_schema("create_observations")
|
||||
|
||||
assert items["additionalProperties"] is False
|
||||
|
||||
level_requirements = {
|
||||
condition["if"]["properties"]["level"]["const"]: condition["then"]["required"]
|
||||
for condition in cast(list[dict[str, Any]], items["allOf"])
|
||||
}
|
||||
|
||||
assert level_requirements["deductive"] == ["source_ids", "premises"]
|
||||
assert level_requirements["inductive"] == [
|
||||
"source_ids",
|
||||
"sources",
|
||||
"pattern_type",
|
||||
"confidence",
|
||||
]
|
||||
assert level_requirements["contradiction"] == ["source_ids", "sources"]
|
||||
|
||||
|
||||
def test_deductive_specialist_tool_requires_evidence_fields() -> None:
|
||||
items = _observation_items_schema("create_observations_deductive")
|
||||
|
||||
assert TOOLS["create_observations_deductive"]["name"] == (
|
||||
"create_observations_deductive"
|
||||
)
|
||||
assert items["required"] == ["content", "source_ids", "premises"]
|
||||
assert items["properties"]["source_ids"]["minItems"] == 1
|
||||
assert items["properties"]["premises"]["minItems"] == 1
|
||||
|
||||
|
||||
def test_inductive_specialist_tool_requires_pattern_fields() -> None:
|
||||
items = _observation_items_schema("create_observations_inductive")
|
||||
|
||||
assert TOOLS["create_observations_inductive"]["name"] == (
|
||||
"create_observations_inductive"
|
||||
)
|
||||
assert items["required"] == [
|
||||
"content",
|
||||
"source_ids",
|
||||
"sources",
|
||||
"pattern_type",
|
||||
"confidence",
|
||||
]
|
||||
assert items["properties"]["source_ids"]["minItems"] == 2
|
||||
assert items["properties"]["sources"]["minItems"] == 2
|
||||
|
||||
|
||||
def test_dreamer_specialists_use_level_specific_creation_tools() -> None:
|
||||
deduction_tool_names = {tool["name"] for tool in DEDUCTION_SPECIALIST_TOOLS}
|
||||
induction_tool_names = {tool["name"] for tool in INDUCTION_SPECIALIST_TOOLS}
|
||||
|
||||
assert "create_observations_deductive" in deduction_tool_names
|
||||
assert "create_observations_inductive" in induction_tool_names
|
||||
assert "create_observations" not in deduction_tool_names
|
||||
assert "create_observations" not in induction_tool_names
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from anthropic.types import TextBlock, ThinkingBlock, ToolUseBlock
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.llm.backends.anthropic import AnthropicBackend
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_extracts_text_thinking_and_tool_calls() -> None:
|
||||
client = Mock()
|
||||
client.messages.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
content=[
|
||||
ThinkingBlock(
|
||||
type="thinking",
|
||||
thinking="internal reasoning",
|
||||
signature="sig_123",
|
||||
),
|
||||
TextBlock(type="text", text="Hello from Anthropic"),
|
||||
ToolUseBlock(
|
||||
type="tool_use",
|
||||
id="tool_1",
|
||||
name="search",
|
||||
input={"query": "honcho"},
|
||||
),
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
cache_creation_input_tokens=3,
|
||||
cache_read_input_tokens=2,
|
||||
),
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
)
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
model="claude-haiku-4-5",
|
||||
messages=[
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
max_tokens=100,
|
||||
tools=[
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
],
|
||||
thinking_budget_tokens=2048,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
assert result.content == "Hello from Anthropic"
|
||||
assert result.thinking_content == "internal reasoning"
|
||||
assert result.thinking_blocks == [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "internal reasoning",
|
||||
"signature": "sig_123",
|
||||
}
|
||||
]
|
||||
assert result.tool_calls[0].name == "search"
|
||||
assert result.input_tokens == 15
|
||||
assert result.output_tokens == 5
|
||||
assert result.finish_reason == "tool_use"
|
||||
|
||||
await_args = client.messages.create.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Anthropic client call")
|
||||
call = await_args.kwargs
|
||||
assert call["model"] == "claude-haiku-4-5"
|
||||
assert call["system"][0]["text"] == "System prompt"
|
||||
assert call["thinking"] == {"type": "enabled", "budget_tokens": 2048}
|
||||
assert call["tool_choice"] == {"type": "any"}
|
||||
|
||||
|
||||
class StructuredResponse(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_skips_assistant_prefill_for_claude_4_models() -> None:
|
||||
client = Mock()
|
||||
client.messages.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
content=[TextBlock(type="text", text='{"answer":"ok"}')],
|
||||
usage=SimpleNamespace(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
),
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
)
|
||||
|
||||
backend = AnthropicBackend(client)
|
||||
result = await backend.complete(
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, StructuredResponse)
|
||||
assert result.content.answer == "ok"
|
||||
await_args = client.messages.create.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Anthropic client call")
|
||||
call = await_args.kwargs
|
||||
assert len(call["messages"]) == 1
|
||||
assert call["messages"][0]["role"] == "user"
|
||||
assert call["messages"][0]["content"].startswith("Hello\n\nRespond with valid JSON")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_backend_rejects_thinking_effort() -> None:
|
||||
backend = AnthropicBackend(Mock())
|
||||
|
||||
with pytest.raises(ValueError, match="does not support thinking_effort"):
|
||||
await backend.complete(
|
||||
model="claude-haiku-4-5",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
thinking_effort="high",
|
||||
)
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.exceptions import LLMError, ValidationException
|
||||
from src.llm.backends.gemini import GeminiBackend
|
||||
from src.llm.caching import PromptCachePolicy, gemini_cache_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_preserves_thought_signature() -> None:
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(
|
||||
parts=[
|
||||
SimpleNamespace(text="Hello from Gemini"),
|
||||
SimpleNamespace(
|
||||
function_call=SimpleNamespace(
|
||||
name="search",
|
||||
args={"query": "honcho"},
|
||||
),
|
||||
thought_signature="sig_gemini",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
max_tokens=100,
|
||||
thinking_budget_tokens=256,
|
||||
)
|
||||
|
||||
assert result.content == "Hello from Gemini"
|
||||
assert result.tool_calls[0].name == "search"
|
||||
assert result.tool_calls[0].thought_signature == "sig_gemini"
|
||||
|
||||
await_args = client.aio.models.generate_content.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Gemini generate_content call")
|
||||
call = await_args.kwargs
|
||||
assert call["model"] == "gemini-2.5-flash"
|
||||
assert call["config"]["system_instruction"] == "System prompt"
|
||||
assert call["config"]["thinking_config"] == {"thinking_budget": 256}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_maps_thinking_effort_to_thinking_level() -> None:
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
await backend.complete(
|
||||
model="gemini-3-pro-preview",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
thinking_effort="low",
|
||||
)
|
||||
|
||||
await_args = client.aio.models.generate_content.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Gemini generate_content call")
|
||||
call = await_args.kwargs
|
||||
assert call["config"]["thinking_config"] == {"thinking_level": "low"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_rejects_budget_and_effort_together() -> None:
|
||||
backend = GeminiBackend(Mock())
|
||||
|
||||
with pytest.raises(
|
||||
ValidationException,
|
||||
match="does not support sending both thinking_budget_tokens and thinking_effort",
|
||||
):
|
||||
await backend.complete(
|
||||
model="gemini-3-pro-preview",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
thinking_budget_tokens=256,
|
||||
thinking_effort="low",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_raises_on_blocked_response() -> None:
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="SAFETY"),
|
||||
content=SimpleNamespace(parts=[]),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=0,
|
||||
),
|
||||
parsed=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
|
||||
with pytest.raises(LLMError, match="Gemini response blocked"):
|
||||
await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
class StructuredResponse(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_validates_dict_parsed_payload() -> None:
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(parts=[]),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed={"answer": "ok"},
|
||||
text=None,
|
||||
function_calls=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=StructuredResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result.content, StructuredResponse)
|
||||
assert result.content.answer == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_falls_back_to_response_text_and_function_calls() -> None:
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(parts=None),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed=None,
|
||||
text="13 is prime.",
|
||||
function_calls=[
|
||||
SimpleNamespace(name="get_favorite_prime", args={"topic": "test"})
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
assert result.content == "13 is prime."
|
||||
assert result.tool_calls[0].name == "get_favorite_prime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_ignores_mock_text_and_function_call_placeholders() -> (
|
||||
None
|
||||
):
|
||||
client = Mock()
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=Mock(
|
||||
candidates=[
|
||||
Mock(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=None,
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=0,
|
||||
),
|
||||
parsed=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
assert result.content == ""
|
||||
assert result.tool_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_backend_strips_system_and_tools_when_using_cached_content() -> (
|
||||
None
|
||||
):
|
||||
gemini_cache_store._handles.clear() # pyright: ignore[reportPrivateUsage]
|
||||
client = Mock()
|
||||
client.aio.caches.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
name="cachedContents/abc123",
|
||||
expire_time=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
client.aio.models.generate_content = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
candidates=[
|
||||
SimpleNamespace(
|
||||
finish_reason=SimpleNamespace(name="STOP"),
|
||||
content=SimpleNamespace(
|
||||
parts=[SimpleNamespace(text="cached result")]
|
||||
),
|
||||
)
|
||||
],
|
||||
usage_metadata=SimpleNamespace(
|
||||
prompt_token_count=12,
|
||||
candidates_token_count=6,
|
||||
),
|
||||
parsed=None,
|
||||
)
|
||||
)
|
||||
|
||||
backend = GeminiBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
max_tokens=100,
|
||||
tools=[
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice="required",
|
||||
extra_params={
|
||||
"cache_policy": PromptCachePolicy(
|
||||
mode="gemini_cached_content",
|
||||
ttl_seconds=300,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert result.content == "cached result"
|
||||
await_args = client.aio.models.generate_content.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Gemini generate_content call")
|
||||
call = await_args.kwargs
|
||||
assert call["config"]["cached_content"] == "cachedContents/abc123"
|
||||
assert "system_instruction" not in call["config"]
|
||||
assert "tools" not in call["config"]
|
||||
assert "tool_config" not in call["config"]
|
||||
|
||||
|
||||
def test_gemini_sanitize_schema_strips_unsupported_keywords() -> None:
|
||||
"""Gemini's function-declarations validator rejects JSON-Schema keywords
|
||||
outside its narrow allowlist (additionalProperties, allOf, if/then, $ref,
|
||||
anyOf, oneOf, patternProperties, ...). _sanitize_schema must strip them
|
||||
recursively so tool schemas authored for OpenAI/Anthropic don't 400 here.
|
||||
"""
|
||||
raw = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"level": {"type": "string", "enum": ["a", "b"]},
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": False,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"level": {"const": "a"}}},
|
||||
"then": {"required": ["aux"]},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["items"],
|
||||
"$defs": {"Foo": {"type": "string"}},
|
||||
}
|
||||
cleaned = GeminiBackend._sanitize_schema(raw) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Top-level
|
||||
assert "additionalProperties" not in cleaned
|
||||
assert "$defs" not in cleaned
|
||||
assert cleaned["type"] == "object"
|
||||
assert cleaned["required"] == ["items"]
|
||||
|
||||
# Nested under items
|
||||
item_schema = cleaned["properties"]["items"]["items"]
|
||||
assert "additionalProperties" not in item_schema
|
||||
assert "allOf" not in item_schema
|
||||
assert item_schema["properties"]["level"]["enum"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_gemini_convert_tools_sanitizes_parameters_schema() -> None:
|
||||
"""End-to-end: feeding a Pydantic/OpenAI-style schema through _convert_tools
|
||||
must produce a Gemini-safe function_declarations payload."""
|
||||
tools = [
|
||||
{
|
||||
"name": "create_observations",
|
||||
"description": "Create observations.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "string"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["observations"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
converted = GeminiBackend._convert_tools(tools) # pyright: ignore[reportPrivateUsage]
|
||||
params = converted[0]["function_declarations"][0]["parameters"]
|
||||
assert "additionalProperties" not in params
|
||||
assert "additionalProperties" not in params["properties"]["observations"]["items"]
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.exceptions import ValidationException
|
||||
from src.llm.backends.openai import OpenAIBackend
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_uses_gpt5_params_and_extracts_reasoning() -> None:
|
||||
client = Mock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(
|
||||
content="Hello from GPT-5",
|
||||
tool_calls=[],
|
||||
reasoning_details=[
|
||||
SimpleNamespace(
|
||||
content="reasoning summary",
|
||||
model_dump=lambda: {
|
||||
"type": "reasoning",
|
||||
"content": "reasoning summary",
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=4),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
backend = OpenAIBackend(client)
|
||||
result = await backend.complete(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
thinking_effort="high",
|
||||
)
|
||||
|
||||
assert result.content == "Hello from GPT-5"
|
||||
assert result.thinking_content == "reasoning summary"
|
||||
assert result.reasoning_details == [
|
||||
{"type": "reasoning", "content": "reasoning summary"}
|
||||
]
|
||||
assert result.cache_read_input_tokens == 4
|
||||
|
||||
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["model"] == "gpt-5-mini"
|
||||
assert call["max_completion_tokens"] == 100
|
||||
assert call["reasoning_effort"] == "high"
|
||||
assert "max_tokens" not in call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_passes_thinking_effort_through_for_non_gpt5_models() -> (
|
||||
None
|
||||
):
|
||||
client = Mock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(
|
||||
content="Hello from GPT-4.1",
|
||||
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,
|
||||
thinking_effort="low",
|
||||
)
|
||||
|
||||
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["model"] == "gpt-4.1"
|
||||
assert call["max_tokens"] == 100
|
||||
assert call["reasoning_effort"] == "low"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_does_not_treat_proxy_models_with_gpt5_substring_as_gpt5() -> (
|
||||
None
|
||||
):
|
||||
"""Regression: proxy/deployment names containing 'gpt-5' must use `max_tokens`.
|
||||
|
||||
Flexible OpenAI-compatible configuration means operators commonly route through
|
||||
proxies/Azure deployments with IDs like `azure-gpt-5-deployment` or
|
||||
`my-gpt-5-proxy`. A naive substring check would incorrectly send
|
||||
`max_completion_tokens` (a GPT-5-only parameter) to those endpoints.
|
||||
"""
|
||||
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="my-gpt-5-proxy",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
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["max_tokens"] == 100
|
||||
assert "max_completion_tokens" not in call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_rejects_thinking_budget_tokens() -> None:
|
||||
backend = OpenAIBackend(Mock())
|
||||
|
||||
with pytest.raises(
|
||||
ValidationException, match="does not support thinking_budget_tokens"
|
||||
):
|
||||
await backend.complete(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
thinking_budget_tokens=256,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_backend_converts_anthropic_style_tools() -> None:
|
||||
client = Mock()
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(
|
||||
content="Used tools",
|
||||
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,
|
||||
tools=[
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Lookup weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
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["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Lookup weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
assert call["tool_choice"] == "required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"gpt-5",
|
||||
"gpt-5-turbo",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.5-preview",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"o4-preview",
|
||||
],
|
||||
)
|
||||
def test_openai_reasoning_models_use_max_completion_tokens(model: str) -> None:
|
||||
"""Reasoning model families (gpt-5 incl. x.y versions, o1/o3/o4) must send
|
||||
max_completion_tokens, not max_tokens — OpenAI rejects max_tokens for them
|
||||
with 400 unsupported_parameter."""
|
||||
from src.llm.backends.openai import (
|
||||
_uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
assert _uses_max_completion_tokens(model) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-3.5-turbo",
|
||||
"some-proxy-model",
|
||||
],
|
||||
)
|
||||
def test_openai_classic_models_use_max_tokens(model: str) -> None:
|
||||
"""Non-reasoning OpenAI and OpenAI-compatible proxy models stay on
|
||||
the classic max_tokens parameter."""
|
||||
from src.llm.backends.openai import (
|
||||
_uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
assert _uses_max_completion_tokens(model) is False
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
from typing import Any
|
||||
|
||||
from src.llm.conversation import (
|
||||
_is_tool_result_message, # pyright: ignore[reportPrivateUsage]
|
||||
_is_tool_use_message, # pyright: ignore[reportPrivateUsage]
|
||||
truncate_messages_to_fit,
|
||||
)
|
||||
|
||||
|
||||
def test_truncate_messages_to_fit_keeps_last_unit_when_over_limit() -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "x " * 2000},
|
||||
]
|
||||
|
||||
truncated = truncate_messages_to_fit(messages, max_tokens=1)
|
||||
|
||||
assert truncated == messages
|
||||
|
||||
|
||||
def test_truncate_messages_to_fit_preserves_tool_result_pair() -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "old context " * 1000},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
|
||||
]
|
||||
|
||||
truncated = truncate_messages_to_fit(messages, max_tokens=5)
|
||||
|
||||
assert truncated == messages[1:]
|
||||
|
||||
|
||||
def test_is_tool_use_message_detects_gemini_function_call_in_parts() -> None:
|
||||
msg: dict[str, Any] = {
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"function_call": {"name": "search", "args": {"q": "honcho"}}},
|
||||
],
|
||||
}
|
||||
assert _is_tool_use_message(msg) is True
|
||||
|
||||
|
||||
def test_is_tool_result_message_detects_gemini_function_response_in_parts() -> None:
|
||||
msg: dict[str, Any] = {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"function_response": {"name": "search", "response": {"result": "ok"}}},
|
||||
],
|
||||
}
|
||||
assert _is_tool_result_message(msg) is True
|
||||
|
||||
|
||||
def test_is_tool_use_message_detects_anthropic_tool_use_block() -> None:
|
||||
msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "calling lookup"},
|
||||
{"type": "tool_use", "id": "t_1", "name": "lookup", "input": {}},
|
||||
],
|
||||
}
|
||||
assert _is_tool_use_message(msg) is True
|
||||
|
||||
|
||||
def test_truncate_messages_to_fit_preserves_gemini_tool_pair() -> None:
|
||||
"""A Gemini-shaped function_call / function_response pair must stay
|
||||
grouped when older units get dropped. Regression: before adding the
|
||||
parts-based detection, neither message would be recognized as a tool
|
||||
unit, and truncation could split or drop them individually."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "parts": [{"text": "old context " * 1000}]},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"function_call": {"name": "lookup", "args": {}}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"function_response": {
|
||||
"name": "lookup",
|
||||
"response": {"result": "found"},
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
truncated = truncate_messages_to_fit(messages, max_tokens=20)
|
||||
|
||||
# The oldest (bulk-text) message should be dropped; the function_call +
|
||||
# function_response pair stays intact together.
|
||||
assert truncated == messages[1:]
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import pytest
|
||||
|
||||
from src.config import ModelConfig, settings
|
||||
from src.llm.credentials import resolve_credentials
|
||||
|
||||
|
||||
def test_transport_credentials_use_global_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings.LLM, "ANTHROPIC_API_KEY", "anthropic-test-key")
|
||||
|
||||
credentials = resolve_credentials(
|
||||
ModelConfig(model="claude-haiku-4-5", transport="anthropic")
|
||||
)
|
||||
|
||||
assert credentials == {"api_key": "anthropic-test-key", "api_base": None}
|
||||
|
||||
|
||||
def test_openai_transport_credentials_use_per_model_config() -> None:
|
||||
credentials = resolve_credentials(
|
||||
ModelConfig(
|
||||
model="my-local-model",
|
||||
transport="openai",
|
||||
api_key="local-key",
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
)
|
||||
|
||||
assert credentials == {
|
||||
"api_key": "local-key",
|
||||
"api_base": "http://localhost:8000/v1",
|
||||
}
|
||||
|
||||
|
||||
def test_openai_transport_credentials_fall_back_to_global_defaults(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings.LLM, "OPENAI_API_KEY", "openai-test-key")
|
||||
|
||||
credentials = resolve_credentials(
|
||||
ModelConfig(
|
||||
model="my-local-model",
|
||||
transport="openai",
|
||||
)
|
||||
)
|
||||
|
||||
assert credentials == {
|
||||
"api_key": "openai-test-key",
|
||||
"api_base": None,
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import EmbeddingModelConfig
|
||||
from src.embedding_client import _EmbeddingClient # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
class FakeOpenAIEmbeddingsAPI:
|
||||
def __init__(self, embedding: list[float]) -> None:
|
||||
self.embedding: list[float] = embedding
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create(self, *, model: str, input: str | list[str]) -> SimpleNamespace:
|
||||
self.calls.append({"model": model, "input": input})
|
||||
if isinstance(input, list):
|
||||
data = [SimpleNamespace(embedding=self.embedding) for _ in input]
|
||||
else:
|
||||
data = [SimpleNamespace(embedding=self.embedding)]
|
||||
return SimpleNamespace(data=data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_client_uses_configured_model_and_dimensions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 8)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.base_url: str | None = base_url
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
api_key="test-key",
|
||||
base_url="http://localhost:8000/v1",
|
||||
),
|
||||
vector_dimensions=8,
|
||||
max_input_tokens=8192,
|
||||
max_tokens_per_request=300_000,
|
||||
)
|
||||
|
||||
embedding = await client.embed("hello world")
|
||||
|
||||
assert embedding == [0.1] * 8
|
||||
assert fake_embeddings.calls == [
|
||||
{"model": "text-embedding-3-small", "input": "hello world"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_client_rejects_dimension_mismatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 7)
|
||||
|
||||
class FakeOpenAIClient:
|
||||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
api_key="test-key",
|
||||
),
|
||||
vector_dimensions=8,
|
||||
max_input_tokens=8192,
|
||||
max_tokens_per_request=300_000,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
|
||||
await client.embed("hello world")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_embedding_client_uses_output_dimensionality(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeGeminiModels:
|
||||
async def embed_content(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
contents: str | list[str],
|
||||
config: dict[str, Any],
|
||||
) -> SimpleNamespace:
|
||||
calls.append(
|
||||
{
|
||||
"model": model,
|
||||
"contents": contents,
|
||||
"config": config,
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
embeddings=[SimpleNamespace(values=[0.2] * 12)],
|
||||
)
|
||||
|
||||
class FakeGeminiClient:
|
||||
def __init__(self, *, api_key: str | None, http_options: Any) -> None:
|
||||
self.api_key: str | None = api_key
|
||||
self.http_options: Any = http_options
|
||||
self.aio: Any = SimpleNamespace(models=FakeGeminiModels())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
transport="gemini",
|
||||
model="gemini-embedding-001",
|
||||
api_key="gemini-key",
|
||||
base_url="https://gemini-proxy.example/v1beta",
|
||||
),
|
||||
vector_dimensions=12,
|
||||
max_input_tokens=4096,
|
||||
max_tokens_per_request=300_000,
|
||||
)
|
||||
|
||||
embedding = await client.embed("hello world")
|
||||
|
||||
assert embedding == [0.2] * 12
|
||||
assert calls == [
|
||||
{
|
||||
"model": "gemini-embedding-001",
|
||||
"contents": "hello world",
|
||||
"config": {"output_dimensionality": 12},
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
from src.llm.backend import CompletionResult, ToolCallResult
|
||||
from src.llm.history_adapters import (
|
||||
AnthropicHistoryAdapter,
|
||||
GeminiHistoryAdapter,
|
||||
OpenAIHistoryAdapter,
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_history_adapter_preserves_thinking_blocks() -> None:
|
||||
adapter = AnthropicHistoryAdapter()
|
||||
result = CompletionResult(
|
||||
content="Done",
|
||||
thinking_blocks=[
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "private reasoning",
|
||||
"signature": "sig_123",
|
||||
}
|
||||
],
|
||||
tool_calls=[
|
||||
ToolCallResult(id="tool_1", name="search", input={"query": "honcho"})
|
||||
],
|
||||
)
|
||||
|
||||
message = adapter.format_assistant_tool_message(result)
|
||||
|
||||
assert message["role"] == "assistant"
|
||||
assert message["content"][0]["type"] == "thinking"
|
||||
assert message["content"][1] == {"type": "text", "text": "Done"}
|
||||
assert message["content"][2]["type"] == "tool_use"
|
||||
|
||||
|
||||
def test_gemini_history_adapter_preserves_thought_signature() -> None:
|
||||
adapter = GeminiHistoryAdapter()
|
||||
result = CompletionResult(
|
||||
content="Calling a tool",
|
||||
tool_calls=[
|
||||
ToolCallResult(
|
||||
id="tool_1",
|
||||
name="search",
|
||||
input={"query": "honcho"},
|
||||
thought_signature="sig_abc",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
message = adapter.format_assistant_tool_message(result)
|
||||
|
||||
assert message["role"] == "model"
|
||||
assert message["parts"][1]["thought_signature"] == "sig_abc"
|
||||
|
||||
|
||||
def test_openai_history_adapter_preserves_reasoning_details() -> None:
|
||||
adapter = OpenAIHistoryAdapter()
|
||||
result = CompletionResult(
|
||||
content="Calling a tool",
|
||||
reasoning_details=[{"type": "reasoning", "content": "step 1"}],
|
||||
tool_calls=[
|
||||
ToolCallResult(id="tool_1", name="search", input={"query": "honcho"})
|
||||
],
|
||||
)
|
||||
|
||||
message = adapter.format_assistant_tool_message(result)
|
||||
|
||||
assert message["role"] == "assistant"
|
||||
assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}]
|
||||
assert message["tool_calls"][0]["function"]["name"] == "search"
|
||||
|
|
@ -0,0 +1,510 @@
|
|||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import (
|
||||
AppSettings,
|
||||
ConfiguredEmbeddingModelSettings,
|
||||
ConfiguredModelSettings,
|
||||
DialecticLevelSettings,
|
||||
DreamSettings,
|
||||
EmbeddingSettings,
|
||||
ModelConfig,
|
||||
ModelOverrideSettings,
|
||||
SummarySettings,
|
||||
VectorStoreSettings,
|
||||
load_toml_config,
|
||||
resolve_embedding_model_config,
|
||||
resolve_model_config,
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_config_is_independent() -> None:
|
||||
"""Fallback config has its own transport and reasoning params."""
|
||||
from src.config import ResolvedFallbackConfig
|
||||
|
||||
config = ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_budget_tokens=1024,
|
||||
fallback=ResolvedFallbackConfig(
|
||||
model="gpt-4.1-mini",
|
||||
transport="openai",
|
||||
base_url="https://example.com/v1",
|
||||
),
|
||||
)
|
||||
assert config.fallback is not None
|
||||
assert config.fallback.transport == "openai"
|
||||
assert config.fallback.thinking_budget_tokens is None
|
||||
assert config.fallback.base_url == "https://example.com/v1"
|
||||
|
||||
|
||||
def test_base_url_is_allowed_for_any_transport() -> None:
|
||||
config = ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
base_url="https://anthropic-proxy.example/v1",
|
||||
)
|
||||
|
||||
assert config.base_url == "https://anthropic-proxy.example/v1"
|
||||
|
||||
|
||||
def test_anthropic_thinking_budget_has_minimum() -> None:
|
||||
with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"):
|
||||
ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_budget_tokens=512,
|
||||
)
|
||||
|
||||
|
||||
def test_reasoning_effort_alias_populates_generic_thinking_effort() -> None:
|
||||
config = ModelConfig.model_validate(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"transport": "openai",
|
||||
"reasoning_effort": "minimal",
|
||||
}
|
||||
)
|
||||
|
||||
assert config.thinking_effort == "minimal"
|
||||
assert config.reasoning_effort == "minimal"
|
||||
|
||||
|
||||
def test_for_model_overrides_model_and_transport() -> None:
|
||||
config = ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
)
|
||||
|
||||
updated = config.for_model(
|
||||
"gpt-5-mini",
|
||||
transport_override="openai",
|
||||
)
|
||||
|
||||
assert updated.model == "gpt-5-mini"
|
||||
assert updated.transport == "openai"
|
||||
assert config.transport == "anthropic"
|
||||
|
||||
|
||||
def test_configured_model_settings_validate_like_runtime_model_config() -> None:
|
||||
with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"):
|
||||
ConfiguredModelSettings(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_budget_tokens=512,
|
||||
)
|
||||
|
||||
|
||||
def test_summary_settings_accept_nested_model_config() -> None:
|
||||
from src.config import FallbackModelSettings
|
||||
|
||||
settings = SummarySettings(
|
||||
MODEL_CONFIG=ConfiguredModelSettings(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
fallback=FallbackModelSettings(
|
||||
model="gemini-2.5-pro",
|
||||
transport="gemini",
|
||||
),
|
||||
thinking_budget_tokens=1024,
|
||||
),
|
||||
)
|
||||
|
||||
assert settings.MODEL_CONFIG.model == "claude-haiku-4-5"
|
||||
assert settings.MODEL_CONFIG.transport == "anthropic"
|
||||
assert settings.MODEL_CONFIG.fallback is not None
|
||||
assert settings.MODEL_CONFIG.fallback.model == "gemini-2.5-pro"
|
||||
assert settings.MODEL_CONFIG.fallback.transport == "gemini"
|
||||
assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024
|
||||
|
||||
|
||||
def test_resolve_model_config_reads_override_env_and_provider_params(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SUMMARY_LOCAL_API_KEY", "test-key")
|
||||
|
||||
configured = ConfiguredModelSettings(
|
||||
model="my-local-model",
|
||||
transport="openai",
|
||||
overrides=ModelOverrideSettings(
|
||||
api_key_env="SUMMARY_LOCAL_API_KEY",
|
||||
base_url="http://localhost:8000/v1",
|
||||
provider_params={"verbosity": "low"},
|
||||
),
|
||||
)
|
||||
|
||||
resolved = resolve_model_config(configured)
|
||||
|
||||
assert resolved.api_key == "test-key"
|
||||
assert resolved.base_url == "http://localhost:8000/v1"
|
||||
assert resolved.provider_params == {"verbosity": "low"}
|
||||
|
||||
|
||||
def test_resolve_embedding_model_config_reads_override_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("EMBEDDING_LOCAL_API_KEY", "embed-key")
|
||||
|
||||
configured = ConfiguredEmbeddingModelSettings(
|
||||
transport="openai",
|
||||
model="text-embedding-3-small",
|
||||
overrides=ModelOverrideSettings(
|
||||
api_key_env="EMBEDDING_LOCAL_API_KEY",
|
||||
base_url="http://localhost:8000/v1",
|
||||
),
|
||||
)
|
||||
|
||||
resolved = resolve_embedding_model_config(configured)
|
||||
|
||||
assert resolved.api_key == "embed-key"
|
||||
assert resolved.base_url == "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_dialectic_level_settings_accepts_nested_model_config() -> None:
|
||||
from src.config import FallbackModelSettings
|
||||
|
||||
settings = DialecticLevelSettings(
|
||||
MODEL_CONFIG=ConfiguredModelSettings(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
fallback=FallbackModelSettings(
|
||||
model="gemini-2.5-pro",
|
||||
transport="gemini",
|
||||
),
|
||||
thinking_budget_tokens=1024,
|
||||
),
|
||||
MAX_TOOL_ITERATIONS=2,
|
||||
)
|
||||
|
||||
resolved = resolve_model_config(settings.MODEL_CONFIG)
|
||||
assert resolved.model == "claude-haiku-4-5"
|
||||
assert resolved.transport == "anthropic"
|
||||
assert resolved.fallback is not None
|
||||
assert resolved.fallback.model == "gemini-2.5-pro"
|
||||
assert resolved.fallback.transport == "gemini"
|
||||
|
||||
|
||||
def test_dialectic_level_settings_require_nested_model_config() -> None:
|
||||
with pytest.raises(ValueError, match="Field required"):
|
||||
DialecticLevelSettings.model_validate({"MAX_TOOL_ITERATIONS": 2})
|
||||
|
||||
|
||||
def test_dialectic_level_settings_reject_legacy_flat_model_shape() -> None:
|
||||
with pytest.raises(ValueError, match="Field required"):
|
||||
DialecticLevelSettings.model_validate(
|
||||
{
|
||||
"MODEL": "claude-haiku-4-5",
|
||||
"THINKING_BUDGET_TOKENS": 1024,
|
||||
"MAX_TOOL_ITERATIONS": 2,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_prefixed_model_strings_are_normalized() -> None:
|
||||
config = ModelConfig.model_validate({"model": "gemini/gemini-2.5-flash"})
|
||||
configured = ConfiguredModelSettings.model_validate(
|
||||
{"model": "anthropic/claude-haiku-4-5"}
|
||||
)
|
||||
|
||||
assert config.transport == "gemini"
|
||||
assert config.model == "gemini-2.5-flash"
|
||||
assert configured.transport == "anthropic"
|
||||
assert configured.model == "claude-haiku-4-5"
|
||||
|
||||
|
||||
def test_dream_specialist_model_configs_are_independent() -> None:
|
||||
"""Specialist configs carry their own defaults and don't inherit from a parent."""
|
||||
|
||||
dream = DreamSettings(
|
||||
DEDUCTION_MODEL_CONFIG=ConfiguredModelSettings(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_budget_tokens=2048,
|
||||
),
|
||||
INDUCTION_MODEL_CONFIG=ConfiguredModelSettings(
|
||||
model="claude-opus-4-1",
|
||||
transport="anthropic",
|
||||
max_output_tokens=8000,
|
||||
),
|
||||
)
|
||||
|
||||
assert dream.DEDUCTION_MODEL_CONFIG.model == "claude-haiku-4-5"
|
||||
assert dream.DEDUCTION_MODEL_CONFIG.thinking_budget_tokens == 2048
|
||||
assert dream.DEDUCTION_MODEL_CONFIG.max_output_tokens is None
|
||||
|
||||
assert dream.INDUCTION_MODEL_CONFIG.model == "claude-opus-4-1"
|
||||
assert dream.INDUCTION_MODEL_CONFIG.max_output_tokens == 8000
|
||||
assert dream.INDUCTION_MODEL_CONFIG.thinking_budget_tokens is None
|
||||
|
||||
|
||||
def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None:
|
||||
settings = AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=True),
|
||||
)
|
||||
|
||||
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048
|
||||
assert settings.VECTOR_STORE.DIMENSIONS == 2048
|
||||
|
||||
|
||||
def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS"
|
||||
),
|
||||
):
|
||||
AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(
|
||||
TYPE="lancedb",
|
||||
MIGRATED=True,
|
||||
DIMENSIONS=1536,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_app_settings_reject_non_1536_dimensions_while_pgvector_or_dual_write_active() -> (
|
||||
None
|
||||
):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
|
||||
):
|
||||
AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE="pgvector", MIGRATED=True),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
|
||||
):
|
||||
AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=False),
|
||||
)
|
||||
|
||||
|
||||
def test_config_toml_example_uses_nested_model_config_sections() -> None:
|
||||
config_path = Path(__file__).resolve().parents[2] / "config.toml.example"
|
||||
config_data = load_toml_config(str(config_path))
|
||||
|
||||
deriver_config = ConfiguredModelSettings.model_validate(
|
||||
config_data["deriver"]["model_config"]
|
||||
)
|
||||
minimal_level = DialecticLevelSettings.model_validate(
|
||||
config_data["dialectic"]["levels"]["minimal"]
|
||||
)
|
||||
max_level = DialecticLevelSettings.model_validate(
|
||||
config_data["dialectic"]["levels"]["max"]
|
||||
)
|
||||
embedding_config = ConfiguredEmbeddingModelSettings.model_validate(
|
||||
config_data["embedding"]["model_config"]
|
||||
)
|
||||
summary_config = ConfiguredModelSettings.model_validate(
|
||||
config_data["summary"]["model_config"]
|
||||
)
|
||||
deduction_model_config = ConfiguredModelSettings.model_validate(
|
||||
config_data["dream"]["deduction_model_config"]
|
||||
)
|
||||
induction_model_config = ConfiguredModelSettings.model_validate(
|
||||
config_data["dream"]["induction_model_config"]
|
||||
)
|
||||
dream = DreamSettings.model_validate(
|
||||
{
|
||||
"DEDUCTION_MODEL_CONFIG": deduction_model_config,
|
||||
"INDUCTION_MODEL_CONFIG": induction_model_config,
|
||||
}
|
||||
)
|
||||
|
||||
# config.toml.example ships the same minimal defaults the app uses:
|
||||
# transport=openai, model=gpt-5.4-mini across every text-generation
|
||||
# feature, with embeddings on openai/text-embedding-3-small. Asserting
|
||||
# these keeps the example file and the in-code defaults in lockstep.
|
||||
assert deriver_config.transport == "openai"
|
||||
assert deriver_config.model == "gpt-5.4-mini"
|
||||
assert deriver_config.thinking_budget_tokens is None
|
||||
assert minimal_level.MODEL_CONFIG.model == "gpt-5.4-mini"
|
||||
assert minimal_level.MODEL_CONFIG.transport == "openai"
|
||||
assert max_level.MODEL_CONFIG.model == "gpt-5.4-mini"
|
||||
assert max_level.MODEL_CONFIG.transport == "openai"
|
||||
assert max_level.MODEL_CONFIG.thinking_budget_tokens is None
|
||||
assert embedding_config.transport == "openai"
|
||||
assert embedding_config.model == "text-embedding-3-small"
|
||||
assert summary_config.model == "gpt-5.4-mini"
|
||||
assert summary_config.transport == "openai"
|
||||
assert dream.DEDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini"
|
||||
assert dream.INDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini"
|
||||
|
||||
|
||||
def test_env_template_uses_nested_model_config_keys() -> None:
|
||||
env_template_path = Path(__file__).resolve().parents[2] / ".env.template"
|
||||
env_template = env_template_path.read_text()
|
||||
|
||||
assert "EMBEDDING_MODEL_CONFIG__MODEL" in env_template
|
||||
assert "EMBEDDING_VECTOR_DIMENSIONS" in env_template
|
||||
assert "DERIVER_MODEL_CONFIG__MODEL" in env_template
|
||||
assert "DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL" in env_template
|
||||
assert "SUMMARY_MODEL_CONFIG__MODEL" in env_template
|
||||
assert "DREAM_DEDUCTION_MODEL_CONFIG__MODEL" in env_template
|
||||
|
||||
assert "DERIVER_PROVIDER=" not in env_template
|
||||
assert "SUMMARY_PROVIDER=" not in env_template
|
||||
assert "DIALECTIC_LEVELS__minimal__PROVIDER=" not in env_template
|
||||
assert "DREAM_PROVIDER=" not in env_template
|
||||
assert "DREAM_DEDUCTION_MODEL=" not in env_template
|
||||
|
||||
|
||||
def _clear_deriver_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Strip any DERIVER_MODEL_CONFIG__* env that would interfere with
|
||||
direct-construction tests."""
|
||||
for name in list(os.environ):
|
||||
if name.startswith("DERIVER_MODEL_CONFIG__") or name == "DERIVER_MODEL_CONFIG":
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_partial_env_override_of_transport_drops_default_thinking_params(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A partial env override of transport must not leak the default's thinking
|
||||
params into a transport that rejects them.
|
||||
|
||||
Regression: setting DERIVER_MODEL_CONFIG__TRANSPORT=openai +
|
||||
DERIVER_MODEL_CONFIG__MODEL=gpt-4.1-mini (without clearing the default
|
||||
thinking_budget_tokens=1024 carried over from the gemini default) used to
|
||||
produce a merged ConfiguredModelSettings with thinking_budget_tokens=1024,
|
||||
which the OpenAI backend then rejected at call time.
|
||||
"""
|
||||
from src.config import DeriverSettings
|
||||
|
||||
_clear_deriver_env(monkeypatch)
|
||||
# Exercise the @model_validator(mode="before") merge path with a raw dict
|
||||
# — pyright can't see through the pre-validator that accepts dict input.
|
||||
settings = DeriverSettings(
|
||||
MODEL_CONFIG={"transport": "openai", "model": "gpt-4.1-mini"}, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert settings.MODEL_CONFIG.transport == "openai"
|
||||
assert settings.MODEL_CONFIG.model == "gpt-4.1-mini"
|
||||
assert settings.MODEL_CONFIG.thinking_budget_tokens is None
|
||||
assert settings.MODEL_CONFIG.thinking_effort is None
|
||||
|
||||
|
||||
def test_partial_env_override_same_transport_keeps_default_thinking_params(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When env preserves the default transport, default thinking params still
|
||||
apply — we only strip on actual transport change.
|
||||
|
||||
The app-level defaults are intentionally minimal (transport + model only)
|
||||
to avoid clobbering operator config, so this test patches in a deliberately
|
||||
rich default to exercise the merge-preservation behavior.
|
||||
"""
|
||||
from src.config import ConfiguredModelSettings, DeriverSettings
|
||||
|
||||
_clear_deriver_env(monkeypatch)
|
||||
|
||||
def _rich_default() -> ConfiguredModelSettings:
|
||||
return ConfiguredModelSettings(
|
||||
transport="gemini",
|
||||
model="gemini-2.5-flash-lite",
|
||||
thinking_budget_tokens=1024,
|
||||
max_output_tokens=4096,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(DeriverSettings, "_MODEL_CONFIG_DEFAULT", _rich_default)
|
||||
|
||||
settings = DeriverSettings(
|
||||
MODEL_CONFIG={"model": "gemini-2.5-pro"}, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert settings.MODEL_CONFIG.transport == "gemini"
|
||||
assert settings.MODEL_CONFIG.model == "gemini-2.5-pro"
|
||||
assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024
|
||||
assert settings.MODEL_CONFIG.max_output_tokens == 4096
|
||||
|
||||
|
||||
def test_explicit_thinking_effort_survives_transport_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User-set thinking params in the override are always preserved."""
|
||||
from src.config import DeriverSettings
|
||||
|
||||
_clear_deriver_env(monkeypatch)
|
||||
settings = DeriverSettings(
|
||||
MODEL_CONFIG={ # pyright: ignore[reportArgumentType]
|
||||
"transport": "openai",
|
||||
"model": "gpt-5",
|
||||
"thinking_effort": "high",
|
||||
},
|
||||
)
|
||||
|
||||
assert settings.MODEL_CONFIG.transport == "openai"
|
||||
assert settings.MODEL_CONFIG.thinking_effort == "high"
|
||||
assert settings.MODEL_CONFIG.thinking_budget_tokens is None
|
||||
|
||||
|
||||
def test_dialectic_level_transport_override_drops_default_thinking_params(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Same leak existed in DialecticSettings._merge_level_defaults.
|
||||
Regression: when a level default has thinking_budget_tokens=0 under a
|
||||
gemini transport and env flips the override to openai, the 0 used to leak
|
||||
through and trip the OpenAI backend's thinking-param rejection.
|
||||
|
||||
The app-level defaults are intentionally minimal (transport + model only)
|
||||
to avoid clobbering operator config, so this test patches in a rich
|
||||
level default to exercise the strip-on-transport-change behavior.
|
||||
|
||||
Exercises the before-validator directly to avoid DialecticSettings'
|
||||
"all 5 levels required" constraint.
|
||||
"""
|
||||
from src.config import (
|
||||
ConfiguredModelSettings,
|
||||
DialecticLevelSettings,
|
||||
DialecticSettings,
|
||||
)
|
||||
|
||||
def _rich_levels() -> dict[str, DialecticLevelSettings]:
|
||||
return {
|
||||
"minimal": DialecticLevelSettings(
|
||||
MODEL_CONFIG=ConfiguredModelSettings(
|
||||
transport="gemini",
|
||||
model="gemini-2.5-flash-lite",
|
||||
thinking_budget_tokens=0,
|
||||
),
|
||||
MAX_TOOL_ITERATIONS=1,
|
||||
MAX_OUTPUT_TOKENS=250,
|
||||
TOOL_CHOICE="any",
|
||||
),
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.config._default_dialectic_levels", _rich_levels)
|
||||
|
||||
data: dict[str, object] = {
|
||||
"LEVELS": {
|
||||
"minimal": {
|
||||
"MODEL_CONFIG": {
|
||||
"transport": "openai",
|
||||
"model": "gpt-4.1-mini",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# The @model_validator decorator wraps the classmethod in a descriptor proxy
|
||||
# that pyright can't see as callable; at runtime pydantic routes it correctly.
|
||||
merged = cast(
|
||||
dict[str, Any],
|
||||
DialecticSettings._merge_level_defaults(data), # pyright: ignore[reportPrivateUsage, reportCallIssue]
|
||||
)
|
||||
levels = cast(dict[str, dict[str, Any]], merged["LEVELS"])
|
||||
minimal_mc = cast(dict[str, Any], levels["minimal"]["MODEL_CONFIG"])
|
||||
assert minimal_mc["transport"] == "openai"
|
||||
assert minimal_mc["model"] == "gpt-4.1-mini"
|
||||
assert "thinking_budget_tokens" not in minimal_mc
|
||||
assert "thinking_effort" not in minimal_mc
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
from src.config import ModelConfig
|
||||
from src.llm.caching import PromptCachePolicy
|
||||
from src.llm.request_builder import execute_completion
|
||||
from tests.llm.conftest import FakeBackend
|
||||
|
||||
|
||||
class SampleResponse(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
async def test_gemini_explicit_budget_passes_tokens_through_without_adjustment(
|
||||
fake_backend: FakeBackend,
|
||||
) -> None:
|
||||
config = ModelConfig(
|
||||
model="gemini-2.5-flash",
|
||||
transport="gemini",
|
||||
thinking_budget_tokens=256,
|
||||
)
|
||||
|
||||
await execute_completion(
|
||||
fake_backend,
|
||||
config,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call = fake_backend.calls[0]
|
||||
# No auto-adjustment — operators set explicit values
|
||||
assert call["max_output_tokens"] == 100
|
||||
assert call["max_tokens"] == 100
|
||||
assert call["thinking_budget_tokens"] == 256
|
||||
|
||||
|
||||
async def test_thinking_params_are_passed_through_without_capability_dropping(
|
||||
fake_backend: FakeBackend,
|
||||
) -> None:
|
||||
config = ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_effort="high",
|
||||
thinking_budget_tokens=1024,
|
||||
)
|
||||
|
||||
await execute_completion(
|
||||
fake_backend,
|
||||
config,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call = fake_backend.calls[0]
|
||||
assert call["thinking_effort"] == "high"
|
||||
assert call["thinking_budget_tokens"] == 1024
|
||||
|
||||
|
||||
async def test_cache_policy_is_passed_through_extra_params(
|
||||
fake_backend: FakeBackend,
|
||||
) -> None:
|
||||
config = ModelConfig(model="gpt-4.1-mini", transport="openai")
|
||||
cache_policy = PromptCachePolicy(mode="prefix", ttl_seconds=300)
|
||||
|
||||
await execute_completion(
|
||||
fake_backend,
|
||||
config,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
response_format=SampleResponse,
|
||||
cache_policy=cache_policy,
|
||||
)
|
||||
|
||||
call = fake_backend.calls[0]
|
||||
assert call["response_format"] is SampleResponse
|
||||
assert call["extra_params"]["cache_policy"] == cache_policy
|
||||
|
||||
|
||||
async def test_provider_params_are_merged_into_extra_params(
|
||||
fake_backend: FakeBackend,
|
||||
) -> None:
|
||||
config = ModelConfig(
|
||||
model="gpt-4.1-mini",
|
||||
transport="openai",
|
||||
top_p=0.9,
|
||||
provider_params={"custom_flag": True},
|
||||
)
|
||||
|
||||
await execute_completion(
|
||||
fake_backend,
|
||||
config,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call = fake_backend.calls[0]
|
||||
assert call["extra_params"]["top_p"] == 0.9
|
||||
assert call["extra_params"]["custom_flag"] is True
|
||||
|
|
@ -576,25 +576,6 @@ def test_get_peer_representation_with_all_parameters(
|
|||
assert isinstance(data["representation"], str)
|
||||
|
||||
|
||||
def test_get_peer_representation_structure(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test that peer representation response has correct structure"""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
# Get representation and validate structure
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/representation",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Validate response structure
|
||||
assert "representation" in data
|
||||
assert isinstance(data["representation"], str)
|
||||
|
||||
|
||||
def test_get_peer_representation_boundary_values(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -74,20 +74,6 @@ class TestDeriverStatusEndpoint:
|
|||
assert response.status_code == 200
|
||||
assert response.json()["total_work_units"] == 0
|
||||
|
||||
async def test_get_deriver_status_with_include_sender_false(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Test getting deriver status with include_sender=False (default)"""
|
||||
workspace, peer = sample_data
|
||||
response = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/queue/status",
|
||||
params={"observer_id": peer.name},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total_work_units"] == 0
|
||||
|
||||
async def test_get_deriver_status_no_parameters(
|
||||
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -20,45 +20,6 @@ def test_create_workspace_with_auth(auth_client: AuthClient):
|
|||
assert response.status_code in [200, 201]
|
||||
|
||||
|
||||
def test_auth_response_time(auth_client: AuthClient):
|
||||
name = str(generate_nanoid())
|
||||
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
response = auth_client.post(
|
||||
"/v3/workspaces", json={"name": name, "metadata": {"key": "value"}}
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
response_time = end_time - start_time
|
||||
print(
|
||||
f"Server response time for client {auth_client.auth_type}: {response_time:.6f} seconds"
|
||||
)
|
||||
|
||||
# Check expected behavior based on auth type
|
||||
if auth_client.auth_type != "admin":
|
||||
assert response.status_code == 401
|
||||
return
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
|
||||
def test_get_or_create_workspace_with_auth(auth_client: AuthClient):
|
||||
name = str(generate_nanoid())
|
||||
|
||||
response = auth_client.post(
|
||||
"/v3/workspaces", json={"name": name, "metadata": {"key": "value"}}
|
||||
)
|
||||
|
||||
if auth_client.auth_type != "admin":
|
||||
assert response.status_code == 401
|
||||
return
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
|
||||
def test_get_workspace_with_auth(
|
||||
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"""
|
||||
Comprehensive tests for src/utils/clients.py
|
||||
Comprehensive tests for the public src.llm orchestration surface.
|
||||
|
||||
Tests cover:
|
||||
- All supported LLM providers (Anthropic, OpenAI, Google/Gemini, Groq)
|
||||
- All supported LLM providers (Anthropic, OpenAI, Google/Gemini)
|
||||
- Streaming and non-streaming responses
|
||||
- Response models (structured output)
|
||||
- Error handling and retries
|
||||
- Provider-specific features
|
||||
- Client initialization
|
||||
- Langfuse integration
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
|
@ -25,13 +23,12 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
|||
from openai.types.completion_usage import CompletionUsage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config import settings
|
||||
from src.exceptions import LLMError
|
||||
from src.utils.clients import (
|
||||
from src.config import ConfiguredModelSettings, ModelConfig, ResolvedFallbackConfig
|
||||
from src.exceptions import LLMError, ValidationException
|
||||
from src.llm import (
|
||||
CLIENTS,
|
||||
HonchoLLMCallResponse,
|
||||
HonchoLLMCallStreamChunk,
|
||||
handle_streaming_response,
|
||||
honcho_llm_call,
|
||||
honcho_llm_call_inner,
|
||||
)
|
||||
|
|
@ -185,45 +182,14 @@ class TestAnthropicClient:
|
|||
model="claude-3-sonnet",
|
||||
prompt="Think about this",
|
||||
max_tokens=100,
|
||||
thinking_budget_tokens=1000,
|
||||
thinking_budget_tokens=1024,
|
||||
)
|
||||
|
||||
# Verify thinking parameter was passed
|
||||
mock_client.messages.create.assert_called_once()
|
||||
call_args = mock_client.messages.create.call_args
|
||||
thinking_config = call_args.kwargs["thinking"]
|
||||
assert thinking_config == {"type": "enabled", "budget_tokens": 1000}
|
||||
|
||||
async def test_anthropic_response_model_with_json_parsing(self):
|
||||
"""Test that Anthropic supports response models via JSON schema in prompt"""
|
||||
from anthropic.types import TextBlock
|
||||
|
||||
# Create an actual Anthropic client mock that passes isinstance checks
|
||||
mock_messages = AsyncMock()
|
||||
mock_response = Mock()
|
||||
# Create an actual TextBlock instance that will pass isinstance checks
|
||||
text_block = TextBlock(type="text", text='"name": "Alice", "age": 30}')
|
||||
mock_response.content = [text_block]
|
||||
mock_response.usage = Mock(output_tokens=10)
|
||||
mock_response.stop_reason = "end_turn"
|
||||
mock_messages.create.return_value = mock_response
|
||||
|
||||
# Instead of mocking the CLIENTS dict, we mock the entire AsyncAnthropic class
|
||||
# to return our configured mock when instantiated
|
||||
with patch("src.utils.clients.AsyncAnthropic") as mock_anthropic_class:
|
||||
mock_client_instance = Mock()
|
||||
mock_client_instance.messages = mock_messages
|
||||
mock_anthropic_class.return_value = mock_client_instance
|
||||
|
||||
# Also need to patch the CLIENTS dict with an instance that passes isinstance
|
||||
# Since this is complex, let's verify the simpler behavior - that response_model
|
||||
# is supported and the prompt is modified (no NotImplementedError)
|
||||
|
||||
# Note: Full integration testing of response_model parsing would require
|
||||
# a more complex setup with actual Anthropic client mocking.
|
||||
# This test verifies that the code path for response_model exists and
|
||||
# modifies the prompt appropriately.
|
||||
pass # Test simplified - behavior is now supported
|
||||
assert thinking_config == {"type": "enabled", "budget_tokens": 1024}
|
||||
|
||||
async def test_anthropic_streaming(self):
|
||||
"""Test Anthropic streaming response"""
|
||||
|
|
@ -253,16 +219,16 @@ class TestAnthropicClient:
|
|||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
chunks: list[HonchoLLMCallStreamChunk] = []
|
||||
async for chunk in handle_streaming_response(
|
||||
client=mock_client,
|
||||
params={
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
json_mode=False,
|
||||
thinking_budget_tokens=None,
|
||||
):
|
||||
stream = await honcho_llm_call_inner(
|
||||
provider="anthropic",
|
||||
model="claude-3-sonnet",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
client_override=mock_client,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 3 # 2 content chunks + 1 final chunk
|
||||
|
|
@ -501,16 +467,16 @@ class TestOpenAIClient:
|
|||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
chunks: list[HonchoLLMCallStreamChunk] = []
|
||||
async for chunk in handle_streaming_response(
|
||||
client=mock_client,
|
||||
params={
|
||||
"model": "gpt-4",
|
||||
"max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
json_mode=False,
|
||||
thinking_budget_tokens=None,
|
||||
):
|
||||
stream = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
client_override=mock_client,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 3
|
||||
|
|
@ -553,9 +519,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-1.5-pro",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
|
|
@ -600,9 +566,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
_response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-1.5-pro",
|
||||
prompt="Generate JSON",
|
||||
max_tokens=100,
|
||||
|
|
@ -637,9 +603,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-1.5-pro",
|
||||
prompt="Generate a person",
|
||||
max_tokens=100,
|
||||
|
|
@ -691,18 +657,18 @@ class TestGoogleClient:
|
|||
)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
chunks: list[HonchoLLMCallStreamChunk] = []
|
||||
async for chunk in handle_streaming_response(
|
||||
client=mock_client,
|
||||
params={
|
||||
"model": "gemini-1.5-pro",
|
||||
"max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
json_mode=False,
|
||||
thinking_budget_tokens=None,
|
||||
):
|
||||
stream = await honcho_llm_call_inner(
|
||||
provider="gemini",
|
||||
model="gemini-1.5-pro",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
client_override=mock_client,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 3
|
||||
|
|
@ -731,9 +697,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-1.5-pro",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
|
|
@ -769,11 +735,11 @@ class TestGoogleClient:
|
|||
mock_client.aio = mock_aio
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"google": mock_client}),
|
||||
patch.dict(CLIENTS, {"gemini": mock_client}),
|
||||
pytest.raises(LLMError, match=f"finish_reason={finish_reason}"),
|
||||
):
|
||||
await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Summarize this",
|
||||
max_tokens=1000,
|
||||
|
|
@ -799,9 +765,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
|
|
@ -829,11 +795,11 @@ class TestGoogleClient:
|
|||
mock_client.aio = mock_aio
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"google": mock_client}),
|
||||
patch.dict(CLIENTS, {"gemini": mock_client}),
|
||||
pytest.raises(LLMError, match="finish_reason=SAFETY"),
|
||||
):
|
||||
await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Generate a person",
|
||||
max_tokens=100,
|
||||
|
|
@ -858,9 +824,9 @@ class TestGoogleClient:
|
|||
mock_aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
mock_client.aio = mock_aio
|
||||
|
||||
with patch.dict(CLIENTS, {"google": mock_client}):
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="google",
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Generate a person",
|
||||
max_tokens=100,
|
||||
|
|
@ -872,244 +838,6 @@ class TestGoogleClient:
|
|||
assert response.finish_reasons == ["SAFETY"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGroqClient:
|
||||
"""Tests for Groq client functionality"""
|
||||
|
||||
async def test_groq_basic_call(self):
|
||||
"""Test basic Groq API call"""
|
||||
from groq import AsyncGroq
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncGroq)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant", content="Hello from Groq"
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=8, total_tokens=18
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"groq": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="groq", model="llama-3.1-70b", prompt="Hello", max_tokens=100
|
||||
)
|
||||
|
||||
assert isinstance(response, HonchoLLMCallResponse)
|
||||
assert response.content == "Hello from Groq"
|
||||
assert response.output_tokens == 8
|
||||
assert response.finish_reasons == ["stop"]
|
||||
|
||||
async def test_groq_json_mode(self):
|
||||
"""Test Groq with JSON mode"""
|
||||
from groq import AsyncGroq
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncGroq)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant", content='{"success": true}'
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=5, total_tokens=15
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"groq": mock_client}):
|
||||
_response = await honcho_llm_call_inner(
|
||||
provider="groq",
|
||||
model="llama-3.1-70b",
|
||||
prompt="Generate JSON",
|
||||
max_tokens=100,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
# Verify JSON mode was set
|
||||
mock_client.chat.completions.create.assert_called_once()
|
||||
call_args = mock_client.chat.completions.create.call_args
|
||||
assert call_args.kwargs["response_format"] == {"type": "json_object"}
|
||||
|
||||
async def test_groq_response_model(self):
|
||||
"""Test Groq with response model (structured output)"""
|
||||
from groq import AsyncGroq
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncGroq)
|
||||
# Mock JSON response that matches SampleTestModel structure
|
||||
json_content = '{"name": "Bob", "age": 30, "active": true}'
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant", content=json_content
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=12, total_tokens=22
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"groq": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="groq",
|
||||
model="llama-3.1-70b",
|
||||
prompt="Generate a person",
|
||||
max_tokens=100,
|
||||
response_model=SampleTestModel,
|
||||
)
|
||||
|
||||
# Verify the response contains the parsed model
|
||||
assert isinstance(response.content, SampleTestModel)
|
||||
assert response.content.name == "Bob"
|
||||
assert response.content.age == 30
|
||||
assert response.content.active is True
|
||||
assert response.output_tokens == 12
|
||||
assert response.finish_reasons == ["stop"]
|
||||
|
||||
# Verify the response format was set to the model
|
||||
mock_client.chat.completions.create.assert_called_once()
|
||||
call_args = mock_client.chat.completions.create.call_args
|
||||
assert call_args.kwargs["response_format"] == SampleTestModel
|
||||
|
||||
async def test_groq_no_content_error(self):
|
||||
"""Test Groq error handling when no content in response"""
|
||||
from groq import AsyncGroq
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncGroq)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content=None),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=0, total_tokens=10
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"groq": mock_client}),
|
||||
pytest.raises(ValueError, match="No content in response"),
|
||||
):
|
||||
await honcho_llm_call_inner(
|
||||
provider="groq",
|
||||
model="llama-3.1-70b",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
async def test_groq_streaming(self):
|
||||
"""Test Groq streaming response"""
|
||||
from groq import AsyncGroq
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncGroq)
|
||||
|
||||
# Create mock streaming chunks
|
||||
mock_chunks = [
|
||||
ChatCompletionChunk(
|
||||
id="test-id",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0, delta=ChoiceDelta(content="Hello"), finish_reason=None
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatCompletionChunk(
|
||||
id="test-id",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=ChoiceDelta(content=" from Groq"),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatCompletionChunk(
|
||||
id="test-id",
|
||||
object="chat.completion.chunk",
|
||||
created=1234567890,
|
||||
model="llama-3.1-70b",
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0, delta=ChoiceDelta(content=None), finish_reason="stop"
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# Create async iterator
|
||||
async def async_chunk_iterator():
|
||||
for chunk in mock_chunks:
|
||||
yield chunk
|
||||
|
||||
# Mock the create method to return the async generator when awaited
|
||||
mock_client.chat.completions.create = AsyncMock(
|
||||
return_value=async_chunk_iterator()
|
||||
)
|
||||
|
||||
with patch.dict(CLIENTS, {"groq": mock_client}):
|
||||
chunks: list[HonchoLLMCallStreamChunk] = []
|
||||
async for chunk in handle_streaming_response(
|
||||
client=mock_client,
|
||||
params={
|
||||
"model": "llama-3.1-70b",
|
||||
"max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
json_mode=False,
|
||||
thinking_budget_tokens=None,
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 3
|
||||
assert chunks[0].content == "Hello"
|
||||
assert chunks[1].content == " from Groq"
|
||||
assert chunks[2].content == ""
|
||||
assert chunks[2].is_done is True
|
||||
assert chunks[2].finish_reasons == ["stop"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMainLLMCallFunction:
|
||||
"""Tests for the main honcho_llm_call function"""
|
||||
|
|
@ -1136,11 +864,12 @@ class TestMainLLMCallFunction:
|
|||
mock_client.messages.stream.return_value = mock_stream
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
settings.DIALECTIC.LEVELS["medium"].PROVIDER = "anthropic"
|
||||
settings.DIALECTIC.LEVELS["medium"].MODEL = "claude-4-sonnet"
|
||||
chunks: list[HonchoLLMCallStreamChunk] = []
|
||||
async for chunk in await honcho_llm_call(
|
||||
llm_settings=settings.DIALECTIC.LEVELS["medium"],
|
||||
model_config=ConfiguredModelSettings(
|
||||
model="claude-4-sonnet",
|
||||
transport="anthropic",
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
|
|
@ -1164,10 +893,11 @@ class TestMainLLMCallFunction:
|
|||
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
settings.DIALECTIC.LEVELS["medium"].PROVIDER = "anthropic"
|
||||
settings.DIALECTIC.LEVELS["medium"].MODEL = "claude-4-sonnet"
|
||||
response = await honcho_llm_call(
|
||||
llm_settings=settings.DIALECTIC.LEVELS["medium"],
|
||||
model_config=ConfiguredModelSettings(
|
||||
model="claude-4-sonnet",
|
||||
transport="anthropic",
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
|
|
@ -1191,44 +921,399 @@ class TestEdgeCases:
|
|||
assert new_chunk.finish_reasons == [] # Should still be empty
|
||||
|
||||
|
||||
# Test fixtures and utilities
|
||||
@pytest.fixture
|
||||
def sample_test_model():
|
||||
"""Fixture providing a sample SampleTestModel instance"""
|
||||
return SampleTestModel(name="Test User", age=25, active=True)
|
||||
@pytest.mark.asyncio
|
||||
class TestModelConfigCalls:
|
||||
async def test_honcho_llm_call_accepts_model_config(self):
|
||||
mock_client = AsyncMock(spec=AsyncAnthropic)
|
||||
mock_response = Mock()
|
||||
mock_response.content = [TextBlock(text="ModelConfig response", type="text")]
|
||||
mock_response.usage = Usage(input_tokens=8, output_tokens=4)
|
||||
mock_response.stop_reason = "stop"
|
||||
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_client():
|
||||
"""Fixture providing a mocked Anthropic client"""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.content = [TextBlock(text="Mocked Anthropic response", type="text")]
|
||||
mock_response.usage = Usage(input_tokens=10, output_tokens=5)
|
||||
mock_response.stop_reason = "stop"
|
||||
mock_client.messages.create.return_value = mock_response
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client():
|
||||
"""Fixture providing a mocked OpenAI client"""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="gpt-4",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant", content="Mocked OpenAI response"
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
response = await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
),
|
||||
finish_reason="stop",
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
assert response.content == "ModelConfig response"
|
||||
await_args = mock_client.messages.create.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Anthropic create call")
|
||||
call_args = await_args.kwargs
|
||||
assert call_args["model"] == "claude-haiku-4-5"
|
||||
|
||||
async def test_honcho_llm_call_accepts_configured_model_settings(self):
|
||||
mock_client = AsyncMock(spec=AsyncAnthropic)
|
||||
mock_response = Mock()
|
||||
mock_response.content = [
|
||||
TextBlock(text="ConfiguredModelSettings response", type="text")
|
||||
]
|
||||
mock_response.usage = Usage(input_tokens=8, output_tokens=4)
|
||||
mock_response.stop_reason = "stop"
|
||||
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
response = await honcho_llm_call(
|
||||
model_config=ConfiguredModelSettings(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
thinking_budget_tokens=1024,
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
assert response.content == "ConfiguredModelSettings response"
|
||||
await_args = mock_client.messages.create.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Anthropic create call")
|
||||
call_args = await_args.kwargs
|
||||
assert call_args["model"] == "claude-haiku-4-5"
|
||||
assert call_args["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1024,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestModelConfigExtraParamsPropagation:
|
||||
"""Regression tests — config knobs must reach the backend.
|
||||
|
||||
Prior to the fix, honcho_llm_call_inner built extra_params from only
|
||||
{json_mode, verbosity}, silently dropping top_p/top_k/frequency_penalty/
|
||||
presence_penalty/seed/provider_params off the ModelConfig. These tests
|
||||
lock in that each backend now receives them.
|
||||
"""
|
||||
|
||||
async def test_openai_propagates_top_p_frequency_seed(self):
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content="ok"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=5, total_tokens=15
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="gpt-4.1",
|
||||
transport="openai",
|
||||
top_p=0.92,
|
||||
frequency_penalty=0.5,
|
||||
presence_penalty=0.1,
|
||||
seed=42,
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
mock_client.chat.completions.create.assert_called_once()
|
||||
kwargs = mock_client.chat.completions.create.call_args.kwargs
|
||||
assert kwargs["top_p"] == 0.92
|
||||
assert kwargs["frequency_penalty"] == 0.5
|
||||
assert kwargs["presence_penalty"] == 0.1
|
||||
assert kwargs["seed"] == 42
|
||||
|
||||
async def test_anthropic_propagates_top_p_top_k(self):
|
||||
mock_client = AsyncMock(spec=AsyncAnthropic)
|
||||
mock_response = Mock()
|
||||
mock_response.content = [TextBlock(text="ok", type="text")]
|
||||
mock_response.usage = Usage(input_tokens=8, output_tokens=4)
|
||||
mock_response.stop_reason = "stop"
|
||||
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
top_p=0.85,
|
||||
top_k=40,
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
await_args = mock_client.messages.create.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected Anthropic create call")
|
||||
kwargs = await_args.kwargs
|
||||
assert kwargs["top_p"] == 0.85
|
||||
assert kwargs["top_k"] == 40
|
||||
|
||||
async def test_provider_params_passthrough(self):
|
||||
"""Operator-supplied provider_params must reach the backend's extra_params.
|
||||
|
||||
Scope: verifies the ModelConfig.provider_params → backend.extra_params
|
||||
boundary inside honcho_llm_call_inner. This is NOT a guarantee that
|
||||
arbitrary keys reach the provider SDK — each backend's _build_params
|
||||
forwards only an allowlist (top_p, top_k, frequency_penalty, seed,
|
||||
etc.). We assert only that the sentinel key arrives in extra_params
|
||||
at the backend boundary, which is the internal contract this test
|
||||
exists to protect.
|
||||
"""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content="ok"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=5, total_tokens=15
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
captured_extra: dict[str, Any] = {}
|
||||
|
||||
from src.llm.backends.openai import OpenAIBackend
|
||||
|
||||
original_complete = OpenAIBackend.complete
|
||||
|
||||
async def capture_extra(self: Any, **kwargs: Any) -> Any:
|
||||
captured_extra.update(kwargs.get("extra_params") or {})
|
||||
return await original_complete(self, **kwargs)
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"openai": mock_client}),
|
||||
patch.object(OpenAIBackend, "complete", capture_extra),
|
||||
):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="gpt-4.1",
|
||||
transport="openai",
|
||||
provider_params={"honcho_sentinel": "zap"},
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
assert captured_extra.get("honcho_sentinel") == "zap"
|
||||
|
||||
async def test_cache_policy_reaches_gemini_backend(self):
|
||||
"""PromptCachePolicy set on ModelConfig must reach the Gemini backend's
|
||||
extra_params as a typed object (so gemini_cached_content reuse fires)."""
|
||||
from google import genai
|
||||
|
||||
from src.config import PromptCachePolicy
|
||||
from src.llm.backends.gemini import GeminiBackend
|
||||
|
||||
mock_client = Mock(spec=genai.Client)
|
||||
mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
import contextlib
|
||||
|
||||
captured_extra: dict[str, Any] = {}
|
||||
|
||||
async def capture_extra(_self: Any, **kwargs: Any) -> Any:
|
||||
captured_extra.update(kwargs.get("extra_params") or {})
|
||||
return None
|
||||
|
||||
policy = PromptCachePolicy(mode="gemini_cached_content", ttl_seconds=300)
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"gemini": mock_client}),
|
||||
patch.object(GeminiBackend, "complete", capture_extra),
|
||||
# capture_extra returns None, so downstream normalization will raise;
|
||||
# we only care that extra_params was observed pre-raise.
|
||||
contextlib.suppress(Exception),
|
||||
):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="gemini-2.5-flash",
|
||||
transport="gemini",
|
||||
cache_policy=policy,
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
assert captured_extra.get("cache_policy") is policy
|
||||
|
||||
async def test_per_call_kwargs_override_provider_params(self):
|
||||
"""json_mode/verbosity from honcho_llm_call must win over provider_params defaults."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from src.llm.backends.openai import OpenAIBackend
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_response = ChatCompletion(
|
||||
id="test-id",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="gpt-4.1",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content="{}"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=10, completion_tokens=5, total_tokens=15
|
||||
),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
captured_extra: dict[str, Any] = {}
|
||||
original_complete = OpenAIBackend.complete
|
||||
|
||||
async def capture_extra(self: Any, **kwargs: Any) -> Any:
|
||||
captured_extra.update(kwargs.get("extra_params") or {})
|
||||
return await original_complete(self, **kwargs)
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"openai": mock_client}),
|
||||
patch.object(OpenAIBackend, "complete", capture_extra),
|
||||
):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="gpt-4.1",
|
||||
transport="openai",
|
||||
provider_params={"json_mode": False, "verbosity": "low"},
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
json_mode=True,
|
||||
verbosity="high",
|
||||
enable_retry=False,
|
||||
)
|
||||
|
||||
assert captured_extra["json_mode"] is True
|
||||
assert captured_extra["verbosity"] == "high"
|
||||
|
||||
async def test_fallback_config_thinking_params_applied_on_final_retry(
|
||||
self,
|
||||
) -> None:
|
||||
"""When primary fails, the FALLBACK ModelConfig's own temperature and
|
||||
thinking_budget_tokens must reach the backend on the final retry —
|
||||
not the primary's values, and not whatever the caller never set.
|
||||
|
||||
Regression for the 'default caller kwargs from runtime_model_config too
|
||||
early' bug: if honcho_llm_call pre-populated temperature from
|
||||
runtime_model_config (the primary) before attempt selection, those
|
||||
primary values would clobber the fallback's own thinking params via
|
||||
effective_config_for_call(update={...}).
|
||||
"""
|
||||
mock_client = AsyncMock(spec=AsyncAnthropic)
|
||||
mock_response = Mock()
|
||||
mock_response.content = [TextBlock(text="from fallback", type="text")]
|
||||
mock_response.usage = Usage(input_tokens=5, output_tokens=3)
|
||||
mock_response.stop_reason = "stop"
|
||||
|
||||
# Primary fails twice, then fallback succeeds on attempt 3.
|
||||
mock_client.messages.create = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("primary attempt 1"),
|
||||
RuntimeError("primary attempt 2"),
|
||||
mock_response,
|
||||
]
|
||||
)
|
||||
|
||||
fallback = ResolvedFallbackConfig(
|
||||
model="claude-haiku-4-5",
|
||||
transport="anthropic",
|
||||
temperature=0.9,
|
||||
thinking_budget_tokens=2048,
|
||||
)
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
await honcho_llm_call(
|
||||
model_config=ModelConfig(
|
||||
model="claude-sonnet-4-5",
|
||||
transport="anthropic",
|
||||
temperature=0.1,
|
||||
thinking_budget_tokens=1024,
|
||||
fallback=fallback,
|
||||
),
|
||||
prompt="Hello",
|
||||
max_tokens=100,
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
)
|
||||
|
||||
# Final call should carry the FALLBACK's values, not primary's.
|
||||
final_call = mock_client.messages.create.await_args_list[-1]
|
||||
kwargs = final_call.kwargs
|
||||
assert kwargs["model"] == "claude-haiku-4-5"
|
||||
assert kwargs["temperature"] == 0.9
|
||||
assert kwargs["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 2048,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestToolLoopValidation:
|
||||
"""Lock in the fail-fast behavior on max_tool_iterations out of range."""
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [0, -1, 101, 1_000])
|
||||
async def test_invalid_max_tool_iterations_raises(self, bad_value: int) -> None:
|
||||
from src.llm.tool_loop import execute_tool_loop
|
||||
|
||||
def _noop_plan() -> Any: # pragma: no cover - never called
|
||||
raise AssertionError("plan should not be invoked for invalid input")
|
||||
|
||||
def _noop_executor(
|
||||
_name: str, _input: dict[str, Any]
|
||||
) -> str: # pragma: no cover
|
||||
return "ok"
|
||||
|
||||
def _noop_retry_callback(_state: Any) -> None: # pragma: no cover
|
||||
return None
|
||||
|
||||
with pytest.raises(ValidationException, match="max_tool_iterations"):
|
||||
await execute_tool_loop(
|
||||
prompt="x",
|
||||
max_tokens=10,
|
||||
messages=None,
|
||||
tools=[{"name": "t", "description": "d", "input_schema": {}}],
|
||||
tool_choice=None,
|
||||
tool_executor=_noop_executor,
|
||||
max_tool_iterations=bad_value,
|
||||
response_model=None,
|
||||
json_mode=False,
|
||||
temperature=None,
|
||||
stop_seqs=None,
|
||||
verbosity=None,
|
||||
enable_retry=False,
|
||||
retry_attempts=3,
|
||||
max_input_tokens=None,
|
||||
get_attempt_plan=_noop_plan,
|
||||
before_retry_callback=_noop_retry_callback,
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
)
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
return mock_client
|
||||
|
|
|
|||
|
|
@ -0,0 +1,456 @@
|
|||
"""
|
||||
Tests for JSON repair handling across all providers in honcho_llm_call_inner,
|
||||
and Gemini thinking budget support.
|
||||
|
||||
Verifies that when an LLM hits the max token limit or returns malformed JSON,
|
||||
the truncated output is repaired and returned instead of crashing.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from anthropic import AsyncAnthropic
|
||||
from anthropic.types import TextBlock, Usage
|
||||
from openai import AsyncOpenAI, LengthFinishReasonError
|
||||
from openai.types.chat import ChatCompletion
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from src.llm import CLIENTS, HonchoLLMCallResponse, honcho_llm_call_inner
|
||||
from src.utils.representation import PromptRepresentation
|
||||
|
||||
# --- Test models ---
|
||||
|
||||
|
||||
class SimpleModel(BaseModel):
|
||||
"""Non-PromptRepresentation model for testing re-raise behavior."""
|
||||
|
||||
items: list[str]
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
VALID_REPR_JSON = {
|
||||
"explicit": [
|
||||
{"content": "hermes is 25 years old"},
|
||||
{"content": "hermes has a dog"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _make_truncated_completion(content: str) -> ChatCompletion:
|
||||
"""Build a ChatCompletion with finish_reason='length' and the given content."""
|
||||
return ChatCompletion(
|
||||
id="test-truncated",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="test-model",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content=content),
|
||||
finish_reason="length",
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
prompt_tokens=1000, completion_tokens=2000, total_tokens=3000
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _raise_length_error(content: str) -> AsyncMock:
|
||||
"""Return an AsyncMock that raises LengthFinishReasonError with truncated content."""
|
||||
completion = _make_truncated_completion(content)
|
||||
return AsyncMock(side_effect=LengthFinishReasonError(completion=completion))
|
||||
|
||||
|
||||
def _make_anthropic_mock(text: str, stop_reason: str = "end_turn") -> AsyncMock:
|
||||
"""Build a mocked AsyncAnthropic client returning the given text."""
|
||||
mock_client = AsyncMock(spec=AsyncAnthropic)
|
||||
mock_response = Mock()
|
||||
mock_response.content = [TextBlock(text=text, type="text")]
|
||||
mock_response.usage = Usage(input_tokens=100, output_tokens=50)
|
||||
mock_response.stop_reason = stop_reason
|
||||
mock_client.messages.create = AsyncMock(return_value=mock_response)
|
||||
return mock_client
|
||||
|
||||
|
||||
def _make_gemini_mock(
|
||||
text: str | None = None,
|
||||
parsed: Any = None,
|
||||
finish_reason_name: str = "STOP",
|
||||
) -> Mock:
|
||||
"""Build a mocked genai.Client returning the given text/parsed content."""
|
||||
mock_client = Mock()
|
||||
|
||||
# Build response
|
||||
mock_response = Mock()
|
||||
mock_response.parsed = parsed
|
||||
|
||||
# Candidates
|
||||
mock_candidate = Mock()
|
||||
mock_finish_reason = Mock()
|
||||
mock_finish_reason.name = finish_reason_name
|
||||
mock_candidate.finish_reason = mock_finish_reason
|
||||
|
||||
# Content parts
|
||||
if text is not None:
|
||||
mock_part = Mock()
|
||||
mock_part.text = text
|
||||
mock_part.function_call = None
|
||||
mock_content = Mock()
|
||||
mock_content.parts = [mock_part]
|
||||
mock_candidate.content = mock_content
|
||||
else:
|
||||
mock_candidate.content = None
|
||||
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
# Usage
|
||||
mock_usage = Mock()
|
||||
mock_usage.prompt_token_count = 200
|
||||
mock_usage.candidates_token_count = 100
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response)
|
||||
return mock_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI / Custom provider tests (LengthFinishReasonError path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestOpenAILengthFinishReasonRepair:
|
||||
"""Tests that LengthFinishReasonError is caught and truncated JSON is repaired."""
|
||||
|
||||
async def test_truncated_prompt_representation_repaired_openai(self) -> None:
|
||||
"""Truncated but repairable PromptRepresentation JSON should be repaired (openai)."""
|
||||
truncated_json = json.dumps(VALID_REPR_JSON)[:-2]
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error(truncated_json)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, HonchoLLMCallResponse)
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert len(response.content.explicit) >= 1
|
||||
assert response.finish_reasons == ["length"]
|
||||
assert response.output_tokens == 2000
|
||||
|
||||
async def test_truncated_prompt_representation_repaired_openai_with_custom_base(
|
||||
self,
|
||||
) -> None:
|
||||
"""Truncated but repairable PromptRepresentation JSON should be repaired."""
|
||||
truncated_json = json.dumps(VALID_REPR_JSON)[:-2]
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error(truncated_json)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, HonchoLLMCallResponse)
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert len(response.content.explicit) >= 1
|
||||
assert response.finish_reasons == ["length"]
|
||||
|
||||
async def test_completely_broken_json_falls_back_to_empty(self) -> None:
|
||||
"""Completely unrepairable JSON should fall back to empty PromptRepresentation."""
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error(
|
||||
"this is not json at all just random text"
|
||||
)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert response.content.explicit == []
|
||||
assert response.finish_reasons == ["length"]
|
||||
|
||||
async def test_empty_content_falls_back_to_empty(self) -> None:
|
||||
"""Empty/null content should fall back to empty PromptRepresentation."""
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error("")
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert response.content.explicit == []
|
||||
|
||||
async def test_non_prompt_representation_reraises_on_unfixable(self) -> None:
|
||||
"""Non-PromptRepresentation with unrepairable JSON should raise ValidationError."""
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error("not json")
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"openai": mock_client}),
|
||||
pytest.raises(ValidationError),
|
||||
):
|
||||
await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Generate items",
|
||||
max_tokens=2000,
|
||||
response_model=SimpleModel,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
async def test_token_counts_preserved(self) -> None:
|
||||
"""Token counts from the truncated completion should be preserved."""
|
||||
truncated_json = '{"explicit": [{"content": "fact one"}'
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error(truncated_json)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert response.input_tokens == 1000
|
||||
assert response.output_tokens == 2000
|
||||
|
||||
async def test_valid_json_with_length_finish_reason(self) -> None:
|
||||
"""Valid JSON despite length truncation should parse fine."""
|
||||
valid_json = json.dumps(VALID_REPR_JSON)
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncOpenAI)
|
||||
mock_client.chat.completions.parse = _raise_length_error(valid_json)
|
||||
|
||||
with patch.dict(CLIENTS, {"openai": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert len(response.content.explicit) == 2
|
||||
assert response.content.explicit[0].content == "hermes is 25 years old"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic provider tests (JSON parse failure -> repair path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAnthropicJsonRepair:
|
||||
"""Tests that Anthropic response_model parse failures trigger JSON repair."""
|
||||
|
||||
async def test_truncated_anthropic_response_repaired(self) -> None:
|
||||
"""Truncated Anthropic JSON response should be repaired."""
|
||||
# Anthropic prefills "{" so the response text starts after that
|
||||
# The code prepends "{" back: json_content = "{" + text_content
|
||||
truncated_text = json.dumps(VALID_REPR_JSON)[
|
||||
1:-2
|
||||
] # Remove leading { and trailing }]
|
||||
|
||||
mock_client = _make_anthropic_mock(truncated_text, stop_reason="max_tokens")
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="anthropic",
|
||||
model="claude-3-sonnet",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert len(response.content.explicit) >= 1
|
||||
|
||||
async def test_broken_anthropic_response_falls_back_to_empty(self) -> None:
|
||||
"""Completely broken Anthropic JSON should fall back to empty PromptRepresentation."""
|
||||
mock_client = _make_anthropic_mock(
|
||||
"random gibberish that is not json", stop_reason="max_tokens"
|
||||
)
|
||||
|
||||
with patch.dict(CLIENTS, {"anthropic": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="anthropic",
|
||||
model="claude-3-sonnet",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert response.content.explicit == []
|
||||
|
||||
async def test_non_prompt_representation_reraises(self) -> None:
|
||||
"""Non-PromptRepresentation with broken JSON should raise."""
|
||||
mock_client = _make_anthropic_mock("not json", stop_reason="max_tokens")
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"anthropic": mock_client}),
|
||||
pytest.raises(ValidationError),
|
||||
):
|
||||
await honcho_llm_call_inner(
|
||||
provider="anthropic",
|
||||
model="claude-3-sonnet",
|
||||
prompt="Generate items",
|
||||
max_tokens=2000,
|
||||
response_model=SimpleModel,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini provider tests (parsed=None or type mismatch -> repair path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGeminiJsonRepair:
|
||||
"""Tests that Gemini response_model parse failures trigger JSON repair."""
|
||||
|
||||
async def test_gemini_unparsed_response_repaired(self) -> None:
|
||||
"""Gemini returning text but no parsed object should repair from raw text."""
|
||||
from google import genai
|
||||
|
||||
valid_text = json.dumps(VALID_REPR_JSON)
|
||||
mock_client = _make_gemini_mock(
|
||||
text=valid_text, parsed=None, finish_reason_name="MAX_TOKENS"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(CLIENTS, {"gemini": mock_client}),
|
||||
patch.object(genai.Client, "__instancecheck__", return_value=True),
|
||||
):
|
||||
# We need the match statement to hit the genai.Client case
|
||||
mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue]
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert len(response.content.explicit) == 2
|
||||
|
||||
async def test_gemini_broken_text_falls_back_to_empty(self) -> None:
|
||||
"""Gemini with broken text and no parsed content should fall back."""
|
||||
from google import genai
|
||||
|
||||
mock_client = _make_gemini_mock(
|
||||
text="broken json", parsed=None, finish_reason_name="MAX_TOKENS"
|
||||
)
|
||||
mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
response = await honcho_llm_call_inner(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Analyze messages",
|
||||
max_tokens=2000,
|
||||
response_model=PromptRepresentation,
|
||||
json_mode=True,
|
||||
)
|
||||
|
||||
assert isinstance(response.content, PromptRepresentation)
|
||||
assert response.content.explicit == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini thinking budget tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGeminiThinkingBudget:
|
||||
"""Tests that thinking_budget_tokens is passed to Gemini via ThinkingConfig."""
|
||||
|
||||
async def test_thinking_budget_passed_to_gemini(self) -> None:
|
||||
"""thinking_budget_tokens should be included in Gemini config."""
|
||||
from google import genai
|
||||
|
||||
mock_client = _make_gemini_mock(text="Hello", parsed=None)
|
||||
mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
await honcho_llm_call_inner(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="Think about this",
|
||||
max_tokens=2000,
|
||||
thinking_budget_tokens=4096,
|
||||
)
|
||||
|
||||
# Verify generate_content was called with thinking_config
|
||||
call_args = mock_client.aio.models.generate_content.call_args
|
||||
config = call_args.kwargs.get("config") or call_args[1].get("config")
|
||||
assert config is not None
|
||||
assert "thinking_config" in config
|
||||
assert config["thinking_config"]["thinking_budget"] == 4096
|
||||
|
||||
async def test_no_thinking_config_when_budget_is_none(self) -> None:
|
||||
"""When thinking_budget_tokens is None, thinking_config should not be set."""
|
||||
from google import genai
|
||||
|
||||
mock_client = _make_gemini_mock(text="Hello", parsed=None)
|
||||
mock_client.__class__ = genai.Client # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
with patch.dict(CLIENTS, {"gemini": mock_client}):
|
||||
await honcho_llm_call_inner(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
prompt="No thinking needed",
|
||||
max_tokens=2000,
|
||||
)
|
||||
|
||||
call_args = mock_client.aio.models.generate_content.call_args
|
||||
config = call_args.kwargs.get("config") or call_args[1].get("config")
|
||||
if config:
|
||||
assert "thinking_config" not in config
|
||||
|
|
@ -10,11 +10,14 @@ from unittest.mock import AsyncMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from src.utils.clients import HonchoLLMCallResponse
|
||||
from src.config import settings
|
||||
from src.llm import HonchoLLMCallResponse
|
||||
from src.utils.summarizer import (
|
||||
Summary,
|
||||
SummaryType,
|
||||
_create_summary, # pyright: ignore[reportPrivateUsage]
|
||||
create_long_summary,
|
||||
create_short_summary,
|
||||
)
|
||||
|
||||
# Common test arguments for _create_summary
|
||||
|
|
@ -217,3 +220,61 @@ class TestCreateSummary:
|
|||
assert is_fallback is True
|
||||
assert summary["content"] == ""
|
||||
assert summary["token_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSummaryCallerMigration:
|
||||
async def test_create_short_summary_uses_model_config(self):
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content="short summary",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["STOP"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.utils.summarizer.honcho_llm_call",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_llm_call:
|
||||
await create_short_summary(
|
||||
formatted_messages=_FORMATTED_MESSAGES,
|
||||
input_tokens=_INPUT_TOKENS,
|
||||
previous_summary=None,
|
||||
)
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected summary LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.SUMMARY.MODEL_CONFIG
|
||||
assert "model_config" in kwargs
|
||||
assert kwargs["model_config"].model == expected_config.model
|
||||
assert "llm_settings" not in kwargs
|
||||
|
||||
async def test_create_long_summary_uses_model_config(self):
|
||||
mock_response = HonchoLLMCallResponse(
|
||||
content="long summary",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
finish_reasons=["STOP"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.utils.summarizer.honcho_llm_call",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_llm_call:
|
||||
await create_long_summary(
|
||||
formatted_messages=_FORMATTED_MESSAGES,
|
||||
previous_summary=None,
|
||||
)
|
||||
|
||||
await_args = mock_llm_call.await_args
|
||||
if await_args is None:
|
||||
raise AssertionError("Expected summary LLM call")
|
||||
kwargs = await_args.kwargs
|
||||
expected_config = settings.SUMMARY.MODEL_CONFIG
|
||||
assert "model_config" in kwargs
|
||||
assert kwargs["model_config"].model == expected_config.model
|
||||
assert "llm_settings" not in kwargs
|
||||
|
|
|
|||
19
uv.lock
19
uv.lock
|
|
@ -1254,23 +1254,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "groq"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -1292,7 +1275,6 @@ dependencies = [
|
|||
{ name = "fastapi-pagination" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "groq" },
|
||||
{ name = "httpx" },
|
||||
{ name = "json-repair" },
|
||||
{ name = "lancedb" },
|
||||
|
|
@ -1349,7 +1331,6 @@ requires-dist = [
|
|||
{ name = "fastapi-pagination", specifier = ">=0.14.2" },
|
||||
{ name = "google-genai", specifier = ">=1.32.0" },
|
||||
{ name = "greenlet", specifier = ">=3.0.3" },
|
||||
{ name = "groq", specifier = ">=0.31.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "json-repair", specifier = ">=0.49.0" },
|
||||
{ name = "lancedb", specifier = ">=0.25.3" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue