apsimo 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/router/router.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""LLMRouter — route LLM requests to the appropriate model tier.
|
|
2
|
+
|
|
3
|
+
Wraps LiteLLM's completion API. All Colony code that calls an LLM
|
|
4
|
+
MUST go through LLMRouter rather than calling LiteLLM directly.
|
|
5
|
+
This centralises cost tracking, fallback logic, and self-learning.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
router = LLMRouter()
|
|
10
|
+
|
|
11
|
+
# Simple routing — scorer picks the cheapest capable tier
|
|
12
|
+
response = await router.complete(messages)
|
|
13
|
+
|
|
14
|
+
# Force a specific tier
|
|
15
|
+
response = await router.complete(messages, force_tier=ModelTier.LARGE)
|
|
16
|
+
|
|
17
|
+
# Provide task context to improve tier selection
|
|
18
|
+
response = await router.complete(
|
|
19
|
+
messages,
|
|
20
|
+
context={"tools": tool_defs, "user_tier": "developer"},
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
import json
|
|
32
|
+
import ipaddress
|
|
33
|
+
import socket
|
|
34
|
+
import threading
|
|
35
|
+
from collections import deque
|
|
36
|
+
from copy import deepcopy
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
# Colony loads its own explicit configuration. LiteLLM otherwise asks
|
|
42
|
+
# python-dotenv to search parent directories during import, which can pull an
|
|
43
|
+
# unrelated operator .env into embedded processes and test collection.
|
|
44
|
+
os.environ.setdefault("PYTHON_DOTENV_DISABLED", "1")
|
|
45
|
+
import litellm # type: ignore[import]
|
|
46
|
+
|
|
47
|
+
from apsimo.router.complexity_scorer import ComplexityScorer
|
|
48
|
+
from apsimo.router.fallback import FallbackHandler
|
|
49
|
+
from apsimo.router.self_learning import RouterSelfLearner
|
|
50
|
+
from apsimo.router.tiers import DEFAULT_TIERS, ModelTier, TierConfig
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _IncompleteFunctionResponse(ValueError):
|
|
56
|
+
"""A completed inference did not produce the requested usable output."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class LLMResponse:
|
|
61
|
+
request_id: str
|
|
62
|
+
tier_used: ModelTier
|
|
63
|
+
model_id: str
|
|
64
|
+
content: str
|
|
65
|
+
usage: dict[str, int] # prompt_tokens, completion_tokens, total_tokens
|
|
66
|
+
latency_ms: int
|
|
67
|
+
cost_usd: float
|
|
68
|
+
raw: Any = field(default=None, repr=False)
|
|
69
|
+
function_role: str = ""
|
|
70
|
+
config_revision: str = ""
|
|
71
|
+
model_revision: str = "unknown"
|
|
72
|
+
binding: str = ""
|
|
73
|
+
prior_attempts: list[dict[str, str]] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LLMRouter:
|
|
77
|
+
"""Route LLM requests to the appropriate model tier."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
tiers: dict[ModelTier, TierConfig] | None = None,
|
|
82
|
+
scorer: ComplexityScorer | None = None,
|
|
83
|
+
self_learner: RouterSelfLearner | None = None,
|
|
84
|
+
fallback_handler: FallbackHandler | None = None,
|
|
85
|
+
event_bus: Any | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._tiers = DEFAULT_TIERS if tiers is None else tiers
|
|
88
|
+
self._snapshot = None
|
|
89
|
+
self._config_path = None
|
|
90
|
+
self._config_stamp = None
|
|
91
|
+
self._config_lock = threading.RLock()
|
|
92
|
+
self._reload_error = None
|
|
93
|
+
self._recent_calls = deque(maxlen=20)
|
|
94
|
+
from .endpoints import EndpointRuntime
|
|
95
|
+
self._endpoints = EndpointRuntime()
|
|
96
|
+
self._scorer = scorer or ComplexityScorer()
|
|
97
|
+
self._fallback = fallback_handler or FallbackHandler()
|
|
98
|
+
self._bus = event_bus
|
|
99
|
+
# Only the retained legacy tier selector consumes this learner.
|
|
100
|
+
# Configuring named functions must not open an unrelated legacy DB.
|
|
101
|
+
self._learner = self_learner
|
|
102
|
+
self._learner_initialized = self_learner is not None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def supports_function_routing(self):
|
|
106
|
+
return self._snapshot is not None
|
|
107
|
+
|
|
108
|
+
def configure(self, host_config, *, config_path=None):
|
|
109
|
+
"""Update this object so every retained consumer sees the same router."""
|
|
110
|
+
from .tiers import build_tiers_from_host
|
|
111
|
+
from .functions import build_snapshot
|
|
112
|
+
if not isinstance(host_config, dict):
|
|
113
|
+
raise ValueError('Host model config must be an object')
|
|
114
|
+
# Only explicitly named deployed models enter function routing. Provider
|
|
115
|
+
# presets and discovery size guesses are not capability declarations.
|
|
116
|
+
raw_tiers = build_tiers_from_host(host_config, configure_environment=False, discover=False)
|
|
117
|
+
tiers = {tier: cfg for tier, cfg in raw_tiers.items() if tier.value in host_config.get('models', {})}
|
|
118
|
+
snapshot = build_snapshot(host_config, tiers)
|
|
119
|
+
with self._config_lock:
|
|
120
|
+
self._snapshot = snapshot
|
|
121
|
+
self._tiers = snapshot.tiers
|
|
122
|
+
if config_path is not None:
|
|
123
|
+
self._config_path = Path(config_path)
|
|
124
|
+
self._config_stamp = None
|
|
125
|
+
self._reload_error = None
|
|
126
|
+
return snapshot.status()
|
|
127
|
+
|
|
128
|
+
def watch_config(self, path):
|
|
129
|
+
self._config_path = Path(path)
|
|
130
|
+
self._config_stamp = None
|
|
131
|
+
|
|
132
|
+
def adopt_configuration(self, prepared, *, config_path):
|
|
133
|
+
with self._config_lock:
|
|
134
|
+
self._snapshot = prepared._snapshot
|
|
135
|
+
self._tiers = self._snapshot.tiers
|
|
136
|
+
self.watch_config(config_path)
|
|
137
|
+
self._reload_error = None
|
|
138
|
+
|
|
139
|
+
def _reload(self):
|
|
140
|
+
if self._config_path is None:
|
|
141
|
+
return
|
|
142
|
+
with self._config_lock:
|
|
143
|
+
try:
|
|
144
|
+
stat = self._config_path.stat()
|
|
145
|
+
stamp = (stat.st_ino, stat.st_mtime_ns, stat.st_size)
|
|
146
|
+
if stamp == self._config_stamp:
|
|
147
|
+
return
|
|
148
|
+
if stat.st_size > 262144:
|
|
149
|
+
raise ValueError('Routing config is too large')
|
|
150
|
+
config = json.loads(self._config_path.read_text())
|
|
151
|
+
self.configure(config)
|
|
152
|
+
self._config_stamp = stamp
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
# Keep the last validated config; never replace it with cloud
|
|
155
|
+
# defaults after a partial write or invalid candidate binding.
|
|
156
|
+
self._reload_error = type(exc).__name__
|
|
157
|
+
|
|
158
|
+
def routing_status(self):
|
|
159
|
+
self._reload()
|
|
160
|
+
snapshot = self._snapshot
|
|
161
|
+
status = snapshot.status() if snapshot else {'config_revision': None, 'roles': {}}
|
|
162
|
+
if snapshot:
|
|
163
|
+
status.update(self._endpoints.status(snapshot))
|
|
164
|
+
return {**status, 'reload_error': self._reload_error, 'recent_calls': list(self._recent_calls)}
|
|
165
|
+
|
|
166
|
+
async def discover_models(self):
|
|
167
|
+
"""Observe configured pool endpoints without changing their declarations."""
|
|
168
|
+
self._reload()
|
|
169
|
+
snapshot = self._snapshot
|
|
170
|
+
if snapshot is None:
|
|
171
|
+
return None
|
|
172
|
+
await self._endpoints.refresh(snapshot, self._probe_models)
|
|
173
|
+
status = {**snapshot.status(), **self._endpoints.status(snapshot),
|
|
174
|
+
'reload_error': self._reload_error, 'recent_calls': list(self._recent_calls)}
|
|
175
|
+
models = {}
|
|
176
|
+
for item in status['model_inventory']:
|
|
177
|
+
if item['available'] and not item['stale']:
|
|
178
|
+
for model in item['models']:
|
|
179
|
+
models.setdefault(model['id'], model)
|
|
180
|
+
return {'models': list(models.values()), 'routing': status,
|
|
181
|
+
'provider': snapshot.provider, 'base_url': snapshot.base_url or None}
|
|
182
|
+
|
|
183
|
+
async def _local_addresses(self, snapshot, binding):
|
|
184
|
+
from .functions import endpoint_host
|
|
185
|
+
host = endpoint_host(binding, snapshot)
|
|
186
|
+
if host is None:
|
|
187
|
+
return []
|
|
188
|
+
try:
|
|
189
|
+
ipaddress.ip_address(host)
|
|
190
|
+
return [host]
|
|
191
|
+
except ValueError:
|
|
192
|
+
addresses = await asyncio.wait_for(asyncio.get_running_loop().getaddrinfo(
|
|
193
|
+
host, None, type=socket.SOCK_STREAM), 2)
|
|
194
|
+
if not addresses or any(not any(ipaddress.ip_address(item[4][0]) in net
|
|
195
|
+
for net in snapshot.networks) for item in addresses):
|
|
196
|
+
return []
|
|
197
|
+
return list(dict.fromkeys(item[4][0] for item in addresses))[:8]
|
|
198
|
+
|
|
199
|
+
async def _at_endpoint(self, snapshot, binding, operation):
|
|
200
|
+
# One configured hostname can resolve to an unavailable address family.
|
|
201
|
+
# Retry only connection failures, within the caller's existing deadline.
|
|
202
|
+
addresses = await self._local_addresses(snapshot, binding)
|
|
203
|
+
if not addresses:
|
|
204
|
+
raise ConnectionError('Endpoint has no eligible resolved address')
|
|
205
|
+
for index, address in enumerate(addresses):
|
|
206
|
+
try:
|
|
207
|
+
return await operation(address)
|
|
208
|
+
except Exception as error:
|
|
209
|
+
if index == len(addresses)-1 or not _connection_failure(error):
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
async def _probe_models(self, snapshot, binding):
|
|
213
|
+
from .endpoints import models_url
|
|
214
|
+
headers = {'Authorization': 'Bearer '+binding.config.api_key} if binding.config.api_key else {}
|
|
215
|
+
async def read(pinned):
|
|
216
|
+
async with _local_http_client(binding.config, pinned) as client:
|
|
217
|
+
async with client.stream('GET', models_url(binding.config.base_url), headers=headers) as response:
|
|
218
|
+
response.raise_for_status()
|
|
219
|
+
data = bytearray()
|
|
220
|
+
async for chunk in response.aiter_bytes():
|
|
221
|
+
data.extend(chunk)
|
|
222
|
+
if len(data) > 262144:
|
|
223
|
+
raise ValueError('Model listing is too large')
|
|
224
|
+
return data
|
|
225
|
+
data = await self._at_endpoint(snapshot, binding, read)
|
|
226
|
+
rows = json.loads(data).get('data')
|
|
227
|
+
if not isinstance(rows, list) or len(rows) > 256:
|
|
228
|
+
raise ValueError('Expected a bounded model listing')
|
|
229
|
+
models = []
|
|
230
|
+
for row in rows:
|
|
231
|
+
if not isinstance(row, dict) or not isinstance(row.get('id'), str) or not 1 <= len(row['id']) <= 256:
|
|
232
|
+
continue
|
|
233
|
+
context = row.get('max_model_len')
|
|
234
|
+
models.append({'id': row['id'],
|
|
235
|
+
'owned_by': row.get('owned_by')[:128] if isinstance(row.get('owned_by'), str) else None,
|
|
236
|
+
'advertised_context_tokens': context if type(context) is int and 0 < context <= 10**9 else None})
|
|
237
|
+
return models
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Public API
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
async def complete(
|
|
244
|
+
self,
|
|
245
|
+
messages: list[dict],
|
|
246
|
+
*,
|
|
247
|
+
force_tier: ModelTier | None = None,
|
|
248
|
+
context: dict | None = None,
|
|
249
|
+
tools: list[dict] | None = None,
|
|
250
|
+
stream: bool = False,
|
|
251
|
+
) -> LLMResponse:
|
|
252
|
+
"""Call the LLM at the cheapest capable tier.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
messages:
|
|
257
|
+
OpenAI-format message list.
|
|
258
|
+
force_tier:
|
|
259
|
+
Skip scoring and use this tier.
|
|
260
|
+
context:
|
|
261
|
+
Hints for the complexity scorer (keys: ``user_tier``, ``messages``,
|
|
262
|
+
``tools``, ``task``).
|
|
263
|
+
tools:
|
|
264
|
+
Tool definitions to pass to LiteLLM.
|
|
265
|
+
stream:
|
|
266
|
+
Reserved legacy argument. The function path rejects True before
|
|
267
|
+
dispatch; Colony consumers currently request complete responses.
|
|
268
|
+
"""
|
|
269
|
+
request_id = str(uuid.uuid4())
|
|
270
|
+
ctx = context or {}
|
|
271
|
+
if tools:
|
|
272
|
+
ctx = {**ctx, "tools": tools}
|
|
273
|
+
|
|
274
|
+
self._reload()
|
|
275
|
+
snapshot = self._snapshot
|
|
276
|
+
if snapshot is not None:
|
|
277
|
+
return await self._call_function(snapshot, messages, ctx, tools, force_tier, stream, request_id)
|
|
278
|
+
if not self._tiers:
|
|
279
|
+
raise ValueError('No validated model configuration is available')
|
|
280
|
+
|
|
281
|
+
# Determine prompt text for scoring (last user message)
|
|
282
|
+
prompt = _last_user_text(messages)
|
|
283
|
+
|
|
284
|
+
if force_tier is not None:
|
|
285
|
+
tier = force_tier
|
|
286
|
+
else:
|
|
287
|
+
tier = self._select_tier(prompt, ctx)
|
|
288
|
+
|
|
289
|
+
return await self._call_with_fallback(
|
|
290
|
+
request_id=request_id,
|
|
291
|
+
messages=messages,
|
|
292
|
+
tier=tier,
|
|
293
|
+
tools=tools,
|
|
294
|
+
stream=stream,
|
|
295
|
+
prompt=prompt,
|
|
296
|
+
max_output_tokens=(
|
|
297
|
+
int(ctx["max_output_tokens"])
|
|
298
|
+
if ctx.get("max_output_tokens") is not None else None
|
|
299
|
+
),
|
|
300
|
+
allow_fallback=ctx.get("allow_fallback", True) is not False,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def route(self, prompt: str, context: dict | None = None) -> tuple[ModelTier, str]:
|
|
304
|
+
"""Select a model tier without making an LLM call.
|
|
305
|
+
|
|
306
|
+
Used by the gateway for pre-call model selection: returns the chosen
|
|
307
|
+
tier and the LiteLLM model string for that tier.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
prompt:
|
|
312
|
+
The user's message text to score.
|
|
313
|
+
context:
|
|
314
|
+
Optional scoring hints (``user_tier``, ``tools``, ``messages``).
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
(tier, model_id)
|
|
319
|
+
"""
|
|
320
|
+
self._reload()
|
|
321
|
+
if self._snapshot is not None:
|
|
322
|
+
cfg = self.function_config(context=context)
|
|
323
|
+
return cfg.tier, cfg.model_id
|
|
324
|
+
tier = self._select_tier(prompt, context or {})
|
|
325
|
+
config = self._tiers.get(tier) or self._tiers.get(ModelTier.MEDIUM)
|
|
326
|
+
model_id = config.model_id if config else ""
|
|
327
|
+
return tier, model_id
|
|
328
|
+
|
|
329
|
+
def tier_config(self, tier: ModelTier) -> TierConfig | None:
|
|
330
|
+
"""Return the TierConfig for *tier* (None if unconfigured).
|
|
331
|
+
|
|
332
|
+
Used by callers that need tier metadata without making a call —
|
|
333
|
+
e.g. the context gate reads ``useful_context_tokens`` to decide
|
|
334
|
+
whether an input needs chunking/retrieval before dispatch.
|
|
335
|
+
"""
|
|
336
|
+
self._reload()
|
|
337
|
+
return self._tiers.get(tier)
|
|
338
|
+
|
|
339
|
+
def function_config(self, *, context=None):
|
|
340
|
+
"""Capability hint for the existing context gate; dispatch rechecks DNS."""
|
|
341
|
+
from .functions import select_role, candidates
|
|
342
|
+
self._reload()
|
|
343
|
+
ctx = context or {}
|
|
344
|
+
snapshot = self._snapshot
|
|
345
|
+
role = select_role(snapshot, ctx)
|
|
346
|
+
if snapshot is None or role not in snapshot.roles:
|
|
347
|
+
return None
|
|
348
|
+
eligible = candidates(snapshot, role, ctx, has_images=False, has_tools=bool(ctx.get('tools')))
|
|
349
|
+
if not eligible:
|
|
350
|
+
raise ValueError('No eligible local model for function ' + role)
|
|
351
|
+
return eligible[0].config
|
|
352
|
+
|
|
353
|
+
def function_deadline_seconds(self, *, context=None):
|
|
354
|
+
"""Configured total role budget for bounded background callers."""
|
|
355
|
+
from .functions import select_role
|
|
356
|
+
self._reload()
|
|
357
|
+
ctx = context or {}
|
|
358
|
+
snapshot = self._snapshot
|
|
359
|
+
role = select_role(snapshot, ctx)
|
|
360
|
+
if snapshot is None or role not in snapshot.roles:
|
|
361
|
+
return None
|
|
362
|
+
return snapshot.roles[role].deadline_seconds
|
|
363
|
+
|
|
364
|
+
async def _call_function(self, snapshot, messages, context, tools, force_tier, stream, request_id):
|
|
365
|
+
from .functions import select_role, candidates
|
|
366
|
+
role_name = select_role(snapshot, context)
|
|
367
|
+
if force_tier == ModelTier.VISION:
|
|
368
|
+
role_name = 'vision'
|
|
369
|
+
if role_name not in snapshot.roles:
|
|
370
|
+
raise ValueError('Unknown function role')
|
|
371
|
+
if stream:
|
|
372
|
+
raise ValueError('Function routing currently returns complete responses')
|
|
373
|
+
# This is a caller's output contract, applied only to explicitly
|
|
374
|
+
# capable bindings. Consumer validation remains necessary on every
|
|
375
|
+
# binding, including prompt-only fallbacks and legacy transports.
|
|
376
|
+
response_format = None
|
|
377
|
+
if context.get('response_schema') is not None:
|
|
378
|
+
schema = deepcopy(context['response_schema'])
|
|
379
|
+
if (not isinstance(schema, dict) or set(schema) != {'name', 'schema'}
|
|
380
|
+
or not isinstance(schema['name'], str) or not 1 <= len(schema['name']) <= 64
|
|
381
|
+
or not isinstance(schema['schema'], dict)):
|
|
382
|
+
raise ValueError('response_schema requires a name and JSON schema object')
|
|
383
|
+
response_format = {'type': 'json_schema', 'json_schema': {**schema, 'strict': True}}
|
|
384
|
+
has_images = any(isinstance(m.get('content'), list) and any(
|
|
385
|
+
isinstance(b, dict) and b.get('type') in {'image_url', 'input_image'} for b in m['content']) for m in messages)
|
|
386
|
+
from apsimo.contextgate import estimate_tokens
|
|
387
|
+
text = '\n'.join(m['content'] if isinstance(m.get('content'), str) else '\n'.join(
|
|
388
|
+
b.get('text', '') for b in (m.get('content') or []) if isinstance(b, dict) and isinstance(b.get('text'), str)) for m in messages)
|
|
389
|
+
schema_tokens = estimate_tokens(json.dumps(response_format)) if response_format else 0
|
|
390
|
+
selection_context = {**context, 'estimated_input_tokens': estimate_tokens(text) + 8 * len(messages),
|
|
391
|
+
'response_schema_tokens': schema_tokens}
|
|
392
|
+
available = candidates(snapshot, role_name, selection_context, has_images=has_images, has_tools=bool(tools))
|
|
393
|
+
if force_tier is not None:
|
|
394
|
+
# Explicit legacy tier selection remains exact, but must still
|
|
395
|
+
# satisfy this call's capabilities and local network restrictions.
|
|
396
|
+
forced = getattr(force_tier, 'value', force_tier)
|
|
397
|
+
available = [b for b in available if b.name == forced]
|
|
398
|
+
if context.get('allow_fallback') is False:
|
|
399
|
+
available = available[:1]
|
|
400
|
+
role = snapshot.roles[role_name]
|
|
401
|
+
deadline = time.monotonic() + role.deadline_seconds
|
|
402
|
+
failures = []
|
|
403
|
+
prior_attempts = []
|
|
404
|
+
seen = set()
|
|
405
|
+
for binding in available:
|
|
406
|
+
cfg = binding.config
|
|
407
|
+
# This finite transport path pins a local OpenAI-compatible client.
|
|
408
|
+
# Provider prefixes may not silently select a cloud SDK instead.
|
|
409
|
+
if not cfg.model_id.startswith('openai/'):
|
|
410
|
+
continue
|
|
411
|
+
endpoint_key = (cfg.base_url, cfg.model_id, binding.weight_revision)
|
|
412
|
+
if endpoint_key in seen:
|
|
413
|
+
continue
|
|
414
|
+
seen.add(endpoint_key)
|
|
415
|
+
remaining = deadline - time.monotonic()
|
|
416
|
+
if remaining <= 0: break
|
|
417
|
+
attempt_timeout = asyncio.timeout(min(role.timeout_seconds, remaining))
|
|
418
|
+
if not self._endpoints.acquire(snapshot, binding, request_id):
|
|
419
|
+
failures.append('EndpointCoolingDown')
|
|
420
|
+
prior_attempts.append({'binding': binding.name, 'model': cfg.model_id,
|
|
421
|
+
'status': 'skipped', 'reason': 'EndpointCoolingDown'})
|
|
422
|
+
continue
|
|
423
|
+
try:
|
|
424
|
+
async def complete_on_address(pinned_ip):
|
|
425
|
+
output = {'response_format': response_format} if response_format and binding.supports_json_schema else {}
|
|
426
|
+
return await self._litellm_call(
|
|
427
|
+
request_id=request_id, config=cfg, messages=messages, tools=tools,
|
|
428
|
+
stream=False, max_output_tokens=context.get('max_output_tokens', context.get('max_tokens')),
|
|
429
|
+
local_endpoint=True, pinned_ip=pinned_ip, **output)
|
|
430
|
+
async with attempt_timeout:
|
|
431
|
+
response = await self._at_endpoint(snapshot, binding, complete_on_address)
|
|
432
|
+
response.function_role = role_name
|
|
433
|
+
response.config_revision = snapshot.revision
|
|
434
|
+
response.model_revision = binding.weight_revision
|
|
435
|
+
response.binding = binding.name
|
|
436
|
+
response.prior_attempts = list(prior_attempts)
|
|
437
|
+
# Even an unusable completion consumed inference. Validate it
|
|
438
|
+
# inside the finite fallback loop, before recording success.
|
|
439
|
+
self._emit_cost_event(response)
|
|
440
|
+
_require_function_output(response, tools)
|
|
441
|
+
self._endpoints.success(snapshot, binding, response)
|
|
442
|
+
self._recent_calls.append({'request_id': request_id, 'function_role': role_name,
|
|
443
|
+
'model_id': cfg.model_id, 'binding': binding.name, 'config_revision': snapshot.revision,
|
|
444
|
+
'weight_revision': binding.weight_revision, 'latency_ms': response.latency_ms})
|
|
445
|
+
return response
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
reason = ('RequestBudgetExceeded' if attempt_timeout.expired() else
|
|
448
|
+
str(exc) if isinstance(exc, _IncompleteFunctionResponse) else type(exc).__name__)
|
|
449
|
+
failures.append(reason)
|
|
450
|
+
prior_attempts.append({'binding': binding.name, 'model': cfg.model_id,
|
|
451
|
+
'status': 'failed', 'reason': reason})
|
|
452
|
+
if not _retryable(exc):
|
|
453
|
+
break
|
|
454
|
+
# A caller's output/context or time budget says nothing about
|
|
455
|
+
# availability for another role. Transport/HTTP failures still
|
|
456
|
+
# cool down the endpoint when our own timer has not expired.
|
|
457
|
+
# A stalled endpoint remains eligible if only our timer expired.
|
|
458
|
+
if (not attempt_timeout.expired() and not isinstance(exc, _IncompleteFunctionResponse)
|
|
459
|
+
and 'contextwindow' not in type(exc).__name__.lower()):
|
|
460
|
+
self._endpoints.failure(snapshot, binding, exc)
|
|
461
|
+
finally:
|
|
462
|
+
self._endpoints.release(snapshot, binding, request_id)
|
|
463
|
+
raise RuntimeError('No eligible local model completed function ' + role_name +
|
|
464
|
+
'; attempts=' + ','.join(failures))
|
|
465
|
+
|
|
466
|
+
def record_outcome(
|
|
467
|
+
self,
|
|
468
|
+
request_id: str,
|
|
469
|
+
tier_used: ModelTier,
|
|
470
|
+
quality_rating: float,
|
|
471
|
+
tokens_used: int,
|
|
472
|
+
latency_ms: int,
|
|
473
|
+
prompt: str = "",
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Record legacy tier outcomes; named functions do not use tier learning."""
|
|
476
|
+
if self._snapshot is not None:
|
|
477
|
+
return
|
|
478
|
+
learner = self._legacy_learner()
|
|
479
|
+
if learner is None:
|
|
480
|
+
return
|
|
481
|
+
config = self._tiers.get(tier_used)
|
|
482
|
+
cost = 0.0
|
|
483
|
+
if config:
|
|
484
|
+
cost = tokens_used * config.cost_per_1k_output / 1000
|
|
485
|
+
|
|
486
|
+
score = self._scorer.score(prompt)
|
|
487
|
+
learner.record(score, tier_used, quality_rating, cost)
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------
|
|
490
|
+
# Internal helpers
|
|
491
|
+
# ------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
def _legacy_learner(self):
|
|
494
|
+
with self._config_lock:
|
|
495
|
+
if not self._learner_initialized:
|
|
496
|
+
self._learner_initialized = True
|
|
497
|
+
try:
|
|
498
|
+
self._learner = RouterSelfLearner()
|
|
499
|
+
except Exception as exc:
|
|
500
|
+
logger.warning("Legacy RouterSelfLearner unavailable: %s", exc)
|
|
501
|
+
return self._learner
|
|
502
|
+
|
|
503
|
+
def _select_tier(self, prompt: str, context: dict) -> ModelTier:
|
|
504
|
+
learner = self._legacy_learner()
|
|
505
|
+
if learner is not None:
|
|
506
|
+
small_cutoff, medium_cutoff = learner.get_thresholds()
|
|
507
|
+
else:
|
|
508
|
+
small_cutoff, medium_cutoff = 0.3, 0.65
|
|
509
|
+
|
|
510
|
+
score = self._scorer.score(prompt, context)
|
|
511
|
+
if score < small_cutoff:
|
|
512
|
+
return ModelTier.SMALL
|
|
513
|
+
elif score < medium_cutoff:
|
|
514
|
+
return ModelTier.MEDIUM
|
|
515
|
+
else:
|
|
516
|
+
return ModelTier.LARGE
|
|
517
|
+
|
|
518
|
+
async def _call_with_fallback(
|
|
519
|
+
self,
|
|
520
|
+
*,
|
|
521
|
+
request_id: str,
|
|
522
|
+
messages: list[dict],
|
|
523
|
+
tier: ModelTier,
|
|
524
|
+
tools: list[dict] | None,
|
|
525
|
+
stream: bool,
|
|
526
|
+
prompt: str,
|
|
527
|
+
max_output_tokens: int | None = None,
|
|
528
|
+
allow_fallback: bool = True,
|
|
529
|
+
) -> LLMResponse:
|
|
530
|
+
current_tier = tier
|
|
531
|
+
last_exc: Exception | None = None
|
|
532
|
+
|
|
533
|
+
while True:
|
|
534
|
+
config = self._tiers.get(current_tier)
|
|
535
|
+
if config is None:
|
|
536
|
+
raise ValueError(f"No TierConfig for tier {current_tier}")
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
response = await self._litellm_call(
|
|
540
|
+
request_id=request_id,
|
|
541
|
+
config=config,
|
|
542
|
+
messages=messages,
|
|
543
|
+
tools=tools,
|
|
544
|
+
stream=stream,
|
|
545
|
+
max_output_tokens=max_output_tokens,
|
|
546
|
+
)
|
|
547
|
+
self._emit_cost_event(response)
|
|
548
|
+
return response
|
|
549
|
+
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
last_exc = exc
|
|
552
|
+
if allow_fallback and self._fallback.should_escalate(exc, current_tier):
|
|
553
|
+
next_t = self._fallback.next_tier(current_tier)
|
|
554
|
+
if next_t is None:
|
|
555
|
+
break
|
|
556
|
+
logger.warning(
|
|
557
|
+
"LLMRouter: escalating %s → %s for request %s",
|
|
558
|
+
current_tier.value,
|
|
559
|
+
next_t.value,
|
|
560
|
+
request_id,
|
|
561
|
+
)
|
|
562
|
+
current_tier = next_t
|
|
563
|
+
else:
|
|
564
|
+
break
|
|
565
|
+
|
|
566
|
+
raise RuntimeError(
|
|
567
|
+
f"LLMRouter: all tiers exhausted for request {request_id}"
|
|
568
|
+
) from last_exc
|
|
569
|
+
|
|
570
|
+
async def _litellm_call(
|
|
571
|
+
self,
|
|
572
|
+
*,
|
|
573
|
+
request_id: str,
|
|
574
|
+
config: TierConfig,
|
|
575
|
+
messages: list[dict],
|
|
576
|
+
tools: list[dict] | None,
|
|
577
|
+
stream: bool,
|
|
578
|
+
max_output_tokens: int | None = None,
|
|
579
|
+
local_endpoint: bool = False,
|
|
580
|
+
pinned_ip: str | None = None,
|
|
581
|
+
response_format: dict | None = None,
|
|
582
|
+
) -> LLMResponse:
|
|
583
|
+
kwargs: dict[str, Any] = {
|
|
584
|
+
"model": config.model_id,
|
|
585
|
+
"messages": messages,
|
|
586
|
+
"max_tokens": (
|
|
587
|
+
min(config.max_tokens, max(1, max_output_tokens))
|
|
588
|
+
if max_output_tokens is not None else config.max_tokens
|
|
589
|
+
),
|
|
590
|
+
}
|
|
591
|
+
if tools:
|
|
592
|
+
kwargs["tools"] = tools
|
|
593
|
+
if response_format is not None:
|
|
594
|
+
kwargs["response_format"] = response_format
|
|
595
|
+
if stream:
|
|
596
|
+
kwargs["stream"] = True
|
|
597
|
+
# Per-tier endpoint overrides — different tiers may live on
|
|
598
|
+
# different servers (see TierConfig). When unset, LiteLLM falls
|
|
599
|
+
# back to the provider-wide env config (OPENAI_API_BASE etc.).
|
|
600
|
+
if config.base_url:
|
|
601
|
+
kwargs["api_base"] = config.base_url
|
|
602
|
+
if config.api_key:
|
|
603
|
+
kwargs["api_key"] = config.api_key
|
|
604
|
+
if config.extra_body:
|
|
605
|
+
kwargs["extra_body"] = dict(config.extra_body)
|
|
606
|
+
|
|
607
|
+
t0 = time.monotonic()
|
|
608
|
+
# LiteLLM's async completion
|
|
609
|
+
if local_endpoint:
|
|
610
|
+
# Prevent environment proxy/credential inheritance and HTTP
|
|
611
|
+
# redirects from turning a local fallback into a remote request.
|
|
612
|
+
from openai import AsyncOpenAI
|
|
613
|
+
async with _local_http_client(config, pinned_ip) as http_client:
|
|
614
|
+
async with AsyncOpenAI(base_url=config.base_url, api_key=config.api_key or 'local-no-key',
|
|
615
|
+
max_retries=0, http_client=http_client) as client:
|
|
616
|
+
raw = await litellm.acompletion(**kwargs, client=client, num_retries=0)
|
|
617
|
+
else:
|
|
618
|
+
raw = await litellm.acompletion(**kwargs)
|
|
619
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
620
|
+
|
|
621
|
+
choice = raw.choices[0]
|
|
622
|
+
content = choice.message.content or ""
|
|
623
|
+
|
|
624
|
+
# Reasoning models (e.g. GLM-5.1, DeepSeek-R1) may put thinking in
|
|
625
|
+
# reasoning_content and leave content empty. Fall back to reasoning
|
|
626
|
+
# content when the final answer is blank but reasoning exists.
|
|
627
|
+
if not content and hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content:
|
|
628
|
+
content = choice.message.reasoning_content
|
|
629
|
+
usage = {
|
|
630
|
+
"prompt_tokens": raw.usage.prompt_tokens if raw.usage else 0,
|
|
631
|
+
"completion_tokens": raw.usage.completion_tokens if raw.usage else 0,
|
|
632
|
+
"total_tokens": raw.usage.total_tokens if raw.usage else 0,
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
cost_usd = _estimate_cost(config, usage)
|
|
636
|
+
|
|
637
|
+
return LLMResponse(
|
|
638
|
+
request_id=request_id,
|
|
639
|
+
tier_used=config.tier,
|
|
640
|
+
model_id=config.model_id,
|
|
641
|
+
content=content,
|
|
642
|
+
usage=usage,
|
|
643
|
+
latency_ms=latency_ms,
|
|
644
|
+
cost_usd=cost_usd,
|
|
645
|
+
raw=raw,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def _emit_cost_event(self, response: LLMResponse) -> None:
|
|
649
|
+
if self._bus is None:
|
|
650
|
+
return
|
|
651
|
+
try:
|
|
652
|
+
self._bus.emit(
|
|
653
|
+
"llm_router.cost",
|
|
654
|
+
{
|
|
655
|
+
"request_id": response.request_id,
|
|
656
|
+
"tier": response.tier_used.value,
|
|
657
|
+
"model": response.model_id,
|
|
658
|
+
"function_role": response.function_role,
|
|
659
|
+
"config_revision": response.config_revision,
|
|
660
|
+
"model_revision": response.model_revision,
|
|
661
|
+
"binding": response.binding,
|
|
662
|
+
"cost_usd": response.cost_usd,
|
|
663
|
+
"latency_ms": response.latency_ms,
|
|
664
|
+
"tokens": response.usage,
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
except Exception as exc: # noqa: BLE001
|
|
668
|
+
logger.debug("LLMRouter: failed to emit cost event: %s", exc)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Helpers
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _last_user_text(messages: list[dict]) -> str:
|
|
677
|
+
"""Extract the text of the last user message for scoring."""
|
|
678
|
+
for msg in reversed(messages):
|
|
679
|
+
if msg.get("role") == "user":
|
|
680
|
+
content = msg.get("content", "")
|
|
681
|
+
if isinstance(content, str):
|
|
682
|
+
return content
|
|
683
|
+
if isinstance(content, list):
|
|
684
|
+
parts = [
|
|
685
|
+
p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
|
|
686
|
+
]
|
|
687
|
+
return " ".join(parts)
|
|
688
|
+
# Fall back to all message content
|
|
689
|
+
return " ".join(
|
|
690
|
+
str(m.get("content", "")) for m in messages if isinstance(m.get("content"), str)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _estimate_cost(config: TierConfig, usage: dict[str, int]) -> float:
|
|
695
|
+
input_cost = usage.get("prompt_tokens", 0) * config.cost_per_1k_input / 1000
|
|
696
|
+
output_cost = usage.get("completion_tokens", 0) * config.cost_per_1k_output / 1000
|
|
697
|
+
return round(input_cost + output_cost, 8)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _local_http_client(config, pinned_ip):
|
|
701
|
+
"""The same explicit local transport for completion and model observation."""
|
|
702
|
+
import httpx
|
|
703
|
+
from urllib.parse import urlsplit
|
|
704
|
+
origin = urlsplit(config.base_url)
|
|
705
|
+
async def pin_request(request):
|
|
706
|
+
if request.url.host != origin.hostname:
|
|
707
|
+
raise ValueError('Unexpected model request host')
|
|
708
|
+
if pinned_ip:
|
|
709
|
+
request.headers['Host'] = origin.netloc
|
|
710
|
+
request.extensions['sni_hostname'] = origin.hostname
|
|
711
|
+
request.url = request.url.copy_with(host=pinned_ip)
|
|
712
|
+
return httpx.AsyncClient(trust_env=False, follow_redirects=False,
|
|
713
|
+
event_hooks={'request': [pin_request]})
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _require_function_output(response, tools):
|
|
717
|
+
"""Keep final text and explicitly requested tool turns, never scratch work.
|
|
718
|
+
|
|
719
|
+
Legacy tier routing keeps its compatibility behavior. Function callers get
|
|
720
|
+
a complete answer or a requested tool turn, within their existing budget.
|
|
721
|
+
Tool argument validation still belongs to the caller's executor.
|
|
722
|
+
"""
|
|
723
|
+
choices = getattr(getattr(response, 'raw', None), 'choices', None)
|
|
724
|
+
if tools and choices:
|
|
725
|
+
choice = choices[0]
|
|
726
|
+
message = getattr(choice, 'message', None)
|
|
727
|
+
if (getattr(choice, 'finish_reason', None) in {None, 'stop', 'tool_calls'}
|
|
728
|
+
and getattr(message, 'tool_calls', None)):
|
|
729
|
+
response.content = getattr(message, 'content', None) or ''
|
|
730
|
+
return
|
|
731
|
+
from apsimo.util.model_output import final_text
|
|
732
|
+
try:
|
|
733
|
+
response.content = final_text(response)
|
|
734
|
+
except ValueError as exc:
|
|
735
|
+
raise _IncompleteFunctionResponse(str(exc)) from exc
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _retryable(exc):
|
|
739
|
+
"""Finite same-role failover, never arbitrary validation/auth retries."""
|
|
740
|
+
if isinstance(exc, (TimeoutError, ConnectionError, OSError, _IncompleteFunctionResponse)):
|
|
741
|
+
return True
|
|
742
|
+
status = getattr(exc, 'status_code', None)
|
|
743
|
+
if status in {404, 408, 429, 500, 502, 503, 504}:
|
|
744
|
+
return True
|
|
745
|
+
name = type(exc).__name__.lower()
|
|
746
|
+
return any(word in name for word in ('timeout', 'connection', 'ratelimit', 'contextwindow'))
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _connection_failure(error):
|
|
750
|
+
"""Recognize transport connect failures even through SDK exception wrappers."""
|
|
751
|
+
from httpx import ConnectError, ConnectTimeout
|
|
752
|
+
pending, seen = [error], set()
|
|
753
|
+
while pending and len(seen) < 16:
|
|
754
|
+
current = pending.pop()
|
|
755
|
+
if id(current) in seen:
|
|
756
|
+
continue
|
|
757
|
+
seen.add(id(current))
|
|
758
|
+
if isinstance(current, (ConnectionError, ConnectError, ConnectTimeout)):
|
|
759
|
+
return True
|
|
760
|
+
pending.extend(item for item in (current.__cause__, current.__context__)
|
|
761
|
+
if isinstance(item, BaseException))
|
|
762
|
+
return False
|