swarph-cli 0.37.0__tar.gz → 0.38.0__tar.gz
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.
- {swarph_cli-0.37.0/src/swarph_cli.egg-info → swarph_cli-0.38.0}/PKG-INFO +3 -1
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/pyproject.toml +7 -1
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.38.0/src/swarph_cli/bench/__init__.py +22 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/backends.py +191 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/pack.py +145 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/prices.py +103 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/quality.py +122 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/refresh_prices.py +110 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/runner.py +217 -0
- swarph_cli-0.38.0/src/swarph_cli/bench/validate.py +283 -0
- swarph_cli-0.38.0/src/swarph_cli/commands/bench.py +257 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/main.py +1 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0/src/swarph_cli.egg-info}/PKG-INFO +3 -1
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/SOURCES.txt +16 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/requires.txt +3 -0
- swarph_cli-0.38.0/tests/test_bench_backends.py +179 -0
- swarph_cli-0.38.0/tests/test_bench_command.py +365 -0
- swarph_cli-0.38.0/tests/test_bench_pack.py +193 -0
- swarph_cli-0.38.0/tests/test_bench_prices.py +93 -0
- swarph_cli-0.38.0/tests/test_bench_quality.py +180 -0
- swarph_cli-0.38.0/tests/test_bench_runner.py +243 -0
- swarph_cli-0.38.0/tests/test_bench_validate.py +318 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_brain_ask_command.py +1 -1
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog.py +1 -1
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/LICENSE +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/README.md +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/setup.cfg +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/chain_token.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/_display.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/board.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/brain.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/brain_ask.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/channel.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/codegraph.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/event.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/gateway.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/highlight.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/lane.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/memory.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/okf_links.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/schedule.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/service.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/spawn.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/timeline.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/delivery_queue.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/schema.sql +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/server.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/services_control.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/multiplexer.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/__init__.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/app.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/providers.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/session_bridge.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/stall_alert.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_board_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_brain_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_caller_meta_guard.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_chain_token.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_channel_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_channel_post.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_codegraph_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_codegraph_mcp.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_caller_convention.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_daemon_delivery.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_delivery_queue.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_display_sanitize.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_event_emit.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_brain.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_client.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_highlight_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_activity_marker.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_install_multiplexer_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_lane_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_main.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_gateway.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_sync_membrane.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_inbox_mark_read.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_inbox_sanitize.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_sidecar_wake_survives_read.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_meta_edge_identity.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_multiplexer.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_okf_links.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_providers_isolation.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_repl_caller_convention.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_schedule_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_security.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_app.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_providers.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_inject.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_probe.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_resolve.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_base_hoist.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_stall_alert.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_timeline_command.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_version_consistency.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_emit_health.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_install_flags.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_model_rung.py +0 -0
- {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_stale_peers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.38.0
|
|
4
4
|
Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -31,6 +31,8 @@ Provides-Extra: dev
|
|
|
31
31
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
32
|
Provides-Extra: mcp
|
|
33
33
|
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
34
|
+
Provides-Extra: bench
|
|
35
|
+
Requires-Dist: google-genai>=0.3; extra == "bench"
|
|
34
36
|
Provides-Extra: gateway
|
|
35
37
|
Requires-Dist: fastapi>=0.115; extra == "gateway"
|
|
36
38
|
Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "swarph-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.38.0"
|
|
8
8
|
description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -60,6 +60,12 @@ dev = ["pytest>=7.0"]
|
|
|
60
60
|
# so the core one-shot/mesh paths don't pull it in; `swarph mcp-server`
|
|
61
61
|
# prints a `pip install swarph-cli[mcp]` hint when it's absent.
|
|
62
62
|
mcp = ["mcp>=1.0"]
|
|
63
|
+
# `swarph bench run` (metered backend) calls the google-genai Developer API
|
|
64
|
+
# directly. The dependency is an extra so `bench`'s pack loader/validate/
|
|
65
|
+
# distance-engine paths (and every other swarph-cli verb) stay usable without
|
|
66
|
+
# it; MeteredGeminiBackend prints a `pip install swarph-cli[bench]` hint via
|
|
67
|
+
# its ImportError message when it's absent.
|
|
68
|
+
bench = ["google-genai>=0.3"]
|
|
63
69
|
# `swarph gateway serve` runs the bundled mesh-gateway HTTP server. The
|
|
64
70
|
# FastAPI/uvicorn stack is an extra so the core client paths stay
|
|
65
71
|
# dependency-light; the verb prints a `pip install swarph-cli[gateway]`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``swarph bench`` — deterministic LLM benchmark-pack runner (board card #101).
|
|
2
|
+
|
|
3
|
+
A *pack* is a self-contained, subject-agnostic unit of {skill, tests, expected
|
|
4
|
+
results} — one JSON file with three required parts: ``system`` (skill/context),
|
|
5
|
+
``tasks[].prompt`` (tests), ``tasks[].expected`` (ground truth). Every score is a
|
|
6
|
+
DISTANCE in [0,1] (0 = perfect); the engine is fixed and domain-agnostic, packs
|
|
7
|
+
ship data only, never code.
|
|
8
|
+
|
|
9
|
+
Ported from the reference implementation (``hedge-fund-mcp/scripts/{model_showdown,
|
|
10
|
+
bench_quality,bench_news98,build_judgment_pack}.py``) per
|
|
11
|
+
``docs/2026-07-24-swarph-bench-pack-spec.md``. See submodules:
|
|
12
|
+
|
|
13
|
+
- :mod:`swarph_cli.bench.quality` — the distance engine (numeric/categorical/
|
|
14
|
+
ranking/text) + answer parsing. Ported verbatim from ``bench_quality.py``.
|
|
15
|
+
- :mod:`swarph_cli.bench.pack` — pack loading + normative JSON-Schema validation.
|
|
16
|
+
- :mod:`swarph_cli.bench.prices` — a small model-id -> ($/1M in, $/1M out) table.
|
|
17
|
+
- :mod:`swarph_cli.bench.backends` — the backend abstraction (metered google-genai
|
|
18
|
+
Developer API + a subscription stub).
|
|
19
|
+
- :mod:`swarph_cli.bench.runner` — the N-way showdown loop -> confusion view.
|
|
20
|
+
- :mod:`swarph_cli.bench.validate` — the four ``validate`` disciplines.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""The backend abstraction (spec §4). ``bench`` OWNS a thin, provider-agnostic
|
|
2
|
+
backend interface — it does NOT reuse the LLM services layer's :878x/:879x
|
|
3
|
+
lane map (decision #3: bench owns its own).
|
|
4
|
+
|
|
5
|
+
A backend maps ``(model_id, prompt, system) -> BackendResult`` (tokens in /
|
|
6
|
+
thought / out, latency, text, whether tokens are ESTIMATED). ``system``
|
|
7
|
+
threads to every backend (metered: ``system_instruction``; a CLI-shelling
|
|
8
|
+
backend would prepend it — see :class:`SubscriptionBackend`).
|
|
9
|
+
|
|
10
|
+
Ships two backends:
|
|
11
|
+
|
|
12
|
+
- :class:`MeteredGeminiBackend` — google-genai Developer API, DEFAULT for v1
|
|
13
|
+
(real ``usage_metadata``: prompt/candidates/thoughts tokens). CRITICAL:
|
|
14
|
+
passes ``vertexai=False`` EXPLICITLY — a ``GOOGLE_GENAI_USE_VERTEXAI=true``
|
|
15
|
+
env 401s API-key auth (this exact bug bit the reference lab; see
|
|
16
|
+
``model_showdown.py::run_metered``).
|
|
17
|
+
- :class:`SubscriptionBackend` — a STUB/interface only. Tokens are always
|
|
18
|
+
ESTIMATED (``~len(text)/4``, flagged via ``estimated=True``) and it does
|
|
19
|
+
NOT couple to any specific subscription lane map; a caller wires an actual
|
|
20
|
+
$0 CLI/OIDC path in by passing ``call_fn``. Not exercised against a live
|
|
21
|
+
provider anywhere in this package.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Callable, Optional, Protocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BackendResult:
|
|
33
|
+
text: str
|
|
34
|
+
tokens_in: int
|
|
35
|
+
tokens_thought: int
|
|
36
|
+
tokens_out: int
|
|
37
|
+
latency_s: float
|
|
38
|
+
estimated: bool
|
|
39
|
+
error: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_tokens(self) -> int:
|
|
43
|
+
return self.tokens_in + (self.tokens_thought or 0) + self.tokens_out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Backend(Protocol):
|
|
47
|
+
def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def missing_creds(self) -> list[str]:
|
|
51
|
+
"""-> human-readable names of missing REQUIRED credentials, e.g.
|
|
52
|
+
``["GEMINI_API_KEY (or GOOGLE_API_KEY)"]``. Empty = ready to dispatch.
|
|
53
|
+
Checked by :func:`swarph_cli.bench.runner.preflight` BEFORE any
|
|
54
|
+
network call, so a missing key surfaces as one clear warning instead
|
|
55
|
+
of a mid-run 401 traceback."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def estimate_tokens(text: Optional[str]) -> int:
|
|
60
|
+
"""Rough ~4-chars/token estimate for backends that report no usage
|
|
61
|
+
metadata (ported from the reference ``_est_tokens``)."""
|
|
62
|
+
return max(1, round(len(text or "") / 4))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MeteredGeminiBackend:
|
|
66
|
+
"""google-genai Developer API — the v1 DEFAULT metered backend. Real
|
|
67
|
+
``usage_metadata`` (prompt/candidates/thoughts token counts), pennies per
|
|
68
|
+
call, no subscription-quota throttling.
|
|
69
|
+
|
|
70
|
+
``google-genai`` is imported LAZILY (inside :meth:`generate`, not at
|
|
71
|
+
module import time) so importing :mod:`swarph_cli.bench.backends` never
|
|
72
|
+
requires the dependency unless the metered backend is actually used —
|
|
73
|
+
consistent with swarph-cli's dependency-light-core-paths convention
|
|
74
|
+
(``[mcp]``/``[gateway]``/``[service]`` extras).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
#: env vars checked (in order) when no api_key is passed to __init__.
|
|
78
|
+
ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
|
|
79
|
+
|
|
80
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
81
|
+
self._api_key = api_key
|
|
82
|
+
|
|
83
|
+
def missing_creds(self) -> list[str]:
|
|
84
|
+
if self._api_key:
|
|
85
|
+
return []
|
|
86
|
+
if any(os.environ.get(v) for v in self.ENV_VARS):
|
|
87
|
+
return []
|
|
88
|
+
return [f"{self.ENV_VARS[0]} (or {self.ENV_VARS[1]})"]
|
|
89
|
+
|
|
90
|
+
def credentials_ok(self) -> bool:
|
|
91
|
+
return not self.missing_creds()
|
|
92
|
+
|
|
93
|
+
def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
|
|
94
|
+
try:
|
|
95
|
+
from google import genai
|
|
96
|
+
from google.genai import types
|
|
97
|
+
except ImportError as exc:
|
|
98
|
+
return BackendResult(
|
|
99
|
+
text="", tokens_in=0, tokens_thought=0, tokens_out=0,
|
|
100
|
+
latency_s=0.0, estimated=False,
|
|
101
|
+
error=f"google-genai not installed: {exc} "
|
|
102
|
+
f"(pip install swarph-cli[bench] or `pip install google-genai`)",
|
|
103
|
+
)
|
|
104
|
+
api_key = self._api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
105
|
+
if not api_key:
|
|
106
|
+
return BackendResult(
|
|
107
|
+
text="", tokens_in=0, tokens_thought=0, tokens_out=0,
|
|
108
|
+
latency_s=0.0, estimated=False,
|
|
109
|
+
error="no API key: set GEMINI_API_KEY (or GOOGLE_API_KEY)",
|
|
110
|
+
)
|
|
111
|
+
t0 = time.time()
|
|
112
|
+
try:
|
|
113
|
+
# vertexai=False EXPLICITLY: if GOOGLE_GENAI_USE_VERTEXAI=true is
|
|
114
|
+
# set in the environment, Vertex rejects API-key auth with a 401.
|
|
115
|
+
# This is not optional — it's the exact bug that bit swarph-news.
|
|
116
|
+
client = genai.Client(api_key=api_key, vertexai=False)
|
|
117
|
+
cfg = types.GenerateContentConfig(system_instruction=system) if system else None
|
|
118
|
+
resp = client.models.generate_content(model=model_id, contents=prompt, config=cfg)
|
|
119
|
+
except Exception as exc: # provider/network errors surface, not swallow silently
|
|
120
|
+
return BackendResult(
|
|
121
|
+
text="", tokens_in=0, tokens_thought=0, tokens_out=0,
|
|
122
|
+
latency_s=round(time.time() - t0, 2), estimated=False, error=str(exc),
|
|
123
|
+
)
|
|
124
|
+
latency_s = round(time.time() - t0, 2)
|
|
125
|
+
usage = getattr(resp, "usage_metadata", None)
|
|
126
|
+
return BackendResult(
|
|
127
|
+
text=getattr(resp, "text", "") or "",
|
|
128
|
+
tokens_in=(usage.prompt_token_count or 0) if usage else 0,
|
|
129
|
+
tokens_thought=(getattr(usage, "thoughts_token_count", 0) or 0) if usage else 0,
|
|
130
|
+
tokens_out=(usage.candidates_token_count or 0) if usage else 0,
|
|
131
|
+
latency_s=latency_s,
|
|
132
|
+
estimated=False,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SubscriptionBackend:
|
|
137
|
+
"""STUB/interface-only backend for a $0 subscription path (e.g. a node's
|
|
138
|
+
own CLI/OIDC lane). Deliberately NOT coupled to the reference stack's
|
|
139
|
+
:878x/:879x lane map (decision #3) — a caller supplies ``call_fn`` to
|
|
140
|
+
wire an actual subscription transport; without one, every call fails
|
|
141
|
+
clearly rather than silently degrading.
|
|
142
|
+
|
|
143
|
+
Token counts are ALWAYS estimated (``estimated=True``) — subscription
|
|
144
|
+
CLIs commonly emit no usage metadata at all. This is UNVERIFIED against
|
|
145
|
+
any live subscription provider; it exists so ``bench`` has a second
|
|
146
|
+
backend shape to validate against without hardcoding a real one.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, call_fn: Optional[Callable[[str, str, str], str]] = None):
|
|
150
|
+
# call_fn(model_id, prompt, system) -> raw response text
|
|
151
|
+
self._call_fn = call_fn
|
|
152
|
+
|
|
153
|
+
def missing_creds(self) -> list[str]:
|
|
154
|
+
"""Not env-checkable (the credential is a node's own CLI/OIDC
|
|
155
|
+
session, which varies per deployment) — per decision, this FLAGS
|
|
156
|
+
rather than hard-checks: no ``call_fn`` wired means there is no
|
|
157
|
+
transport at all, which the preflight treats the same as a missing
|
|
158
|
+
credential so it's skipped with a clear reason instead of erroring
|
|
159
|
+
mid-run."""
|
|
160
|
+
if self._call_fn is None:
|
|
161
|
+
return ["subscription CLI/OIDC auth (not env-checkable; no call_fn wired — stub backend)"]
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
def credentials_ok(self) -> bool:
|
|
165
|
+
return not self.missing_creds()
|
|
166
|
+
|
|
167
|
+
def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
|
|
168
|
+
if self._call_fn is None:
|
|
169
|
+
return BackendResult(
|
|
170
|
+
text="", tokens_in=0, tokens_thought=0, tokens_out=0,
|
|
171
|
+
latency_s=0.0, estimated=True,
|
|
172
|
+
error="SubscriptionBackend is a stub — no call_fn wired for a real "
|
|
173
|
+
"$0 subscription transport",
|
|
174
|
+
)
|
|
175
|
+
t0 = time.time()
|
|
176
|
+
try:
|
|
177
|
+
text = self._call_fn(model_id, prompt, system) or ""
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
return BackendResult(
|
|
180
|
+
text="", tokens_in=0, tokens_thought=0, tokens_out=0,
|
|
181
|
+
latency_s=round(time.time() - t0, 2), estimated=True, error=str(exc),
|
|
182
|
+
)
|
|
183
|
+
latency_s = round(time.time() - t0, 2)
|
|
184
|
+
return BackendResult(
|
|
185
|
+
text=text,
|
|
186
|
+
tokens_in=estimate_tokens(prompt),
|
|
187
|
+
tokens_thought=0,
|
|
188
|
+
tokens_out=estimate_tokens(text),
|
|
189
|
+
latency_s=latency_s,
|
|
190
|
+
estimated=True,
|
|
191
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Pack loader + the normative JSON-Schema validation (spec §1).
|
|
2
|
+
|
|
3
|
+
A pack is one JSON file, three required parts: ``system`` (skill/context),
|
|
4
|
+
``tasks[].prompt`` (tests), ``tasks[].expected`` (ground truth). Hand-rolled
|
|
5
|
+
schema check (no ``jsonschema`` dependency — swarph-cli keeps its core paths
|
|
6
|
+
dependency-light, per the existing ``codegraph``/``compress`` pattern) that
|
|
7
|
+
enforces exactly the normative schema from the spec plus the constraints
|
|
8
|
+
listed beneath it:
|
|
9
|
+
|
|
10
|
+
- ``id`` unique within a pack
|
|
11
|
+
- ``expected`` shape matches ``type`` (numeric->number, categorical->string,
|
|
12
|
+
ranking->array, text->string)
|
|
13
|
+
- ``system`` SHOULD be present (warning, not an error, if absent)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
TASK_TYPES = ("numeric", "categorical", "ranking", "text")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PackError(Exception):
|
|
26
|
+
"""Raised by :func:`load_pack` when the JSON itself can't be read/parsed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_pack(path: str | Path) -> dict:
|
|
30
|
+
"""Read + JSON-parse a pack file. Raises :class:`PackError` on I/O or
|
|
31
|
+
JSON-decode failure (schema validity is a separate step — see
|
|
32
|
+
:func:`validate_schema` — so callers can report *why* a pack is invalid
|
|
33
|
+
rather than crash)."""
|
|
34
|
+
p = Path(path)
|
|
35
|
+
try:
|
|
36
|
+
raw = p.read_text()
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
raise PackError(f"cannot read pack {p}: {exc}") from exc
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(raw)
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
raise PackError(f"pack {p} is not valid JSON: {exc}") from exc
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
raise PackError(f"pack {p} must be a JSON object, got {type(data).__name__}")
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _expected_matches_type(expected: Any, ttype: str) -> bool:
|
|
49
|
+
if ttype == "numeric":
|
|
50
|
+
return isinstance(expected, (int, float)) and not isinstance(expected, bool)
|
|
51
|
+
if ttype == "categorical":
|
|
52
|
+
return isinstance(expected, str)
|
|
53
|
+
if ttype == "ranking":
|
|
54
|
+
return isinstance(expected, list) and all(isinstance(x, (str, int, float)) for x in expected)
|
|
55
|
+
if ttype == "text":
|
|
56
|
+
return isinstance(expected, str)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_schema(pack: dict) -> tuple[list[str], list[str]]:
|
|
61
|
+
"""Validate ``pack`` against the normative JSON-Schema (spec §1) plus the
|
|
62
|
+
constraints beneath it. -> ``(errors, warnings)``. Non-empty ``errors``
|
|
63
|
+
means the pack is INVALID (schema violation, dup id, type/expected
|
|
64
|
+
mismatch); ``warnings`` are advisory (e.g. missing ``system``) and don't
|
|
65
|
+
fail the pack on their own."""
|
|
66
|
+
errors: list[str] = []
|
|
67
|
+
warnings: list[str] = []
|
|
68
|
+
|
|
69
|
+
if not isinstance(pack, dict):
|
|
70
|
+
return ([f"pack must be a JSON object, got {type(pack).__name__}"], warnings)
|
|
71
|
+
|
|
72
|
+
theme = pack.get("theme")
|
|
73
|
+
if not isinstance(theme, str) or not theme.strip():
|
|
74
|
+
errors.append("'theme' is required and must be a non-empty string")
|
|
75
|
+
|
|
76
|
+
if "version" in pack and not isinstance(pack["version"], str):
|
|
77
|
+
errors.append("'version' must be a string when present")
|
|
78
|
+
|
|
79
|
+
if "system" not in pack or not str(pack.get("system") or "").strip():
|
|
80
|
+
warnings.append(
|
|
81
|
+
"pack has no 'system' — a pack without a system tests raw model priors, "
|
|
82
|
+
"not applying a real operating context (spec §5d: system is the DOMINANT "
|
|
83
|
+
"variable and must mirror real deployment)"
|
|
84
|
+
)
|
|
85
|
+
elif not isinstance(pack.get("system"), str):
|
|
86
|
+
errors.append("'system' must be a string when present")
|
|
87
|
+
|
|
88
|
+
if "description" in pack and not isinstance(pack["description"], str):
|
|
89
|
+
errors.append("'description' must be a string when present")
|
|
90
|
+
|
|
91
|
+
tasks = pack.get("tasks")
|
|
92
|
+
if not isinstance(tasks, list) or len(tasks) < 1:
|
|
93
|
+
errors.append("'tasks' is required and must be a non-empty array")
|
|
94
|
+
return (errors, warnings)
|
|
95
|
+
|
|
96
|
+
seen_ids: set[str] = set()
|
|
97
|
+
for i, task in enumerate(tasks):
|
|
98
|
+
where = f"tasks[{i}]"
|
|
99
|
+
if not isinstance(task, dict):
|
|
100
|
+
errors.append(f"{where} must be an object")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
tid = task.get("id")
|
|
104
|
+
if not isinstance(tid, str) or not tid.strip():
|
|
105
|
+
errors.append(f"{where}.id is required and must be a non-empty string")
|
|
106
|
+
elif tid in seen_ids:
|
|
107
|
+
errors.append(f"duplicate task id {tid!r}")
|
|
108
|
+
else:
|
|
109
|
+
seen_ids.add(tid)
|
|
110
|
+
|
|
111
|
+
prompt = task.get("prompt")
|
|
112
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
113
|
+
errors.append(f"{where}.prompt is required and must be a non-empty string")
|
|
114
|
+
|
|
115
|
+
ttype = task.get("type")
|
|
116
|
+
if ttype not in TASK_TYPES:
|
|
117
|
+
errors.append(f"{where}.type must be one of {TASK_TYPES}, got {ttype!r}")
|
|
118
|
+
continue # can't check expected-shape against an unknown type
|
|
119
|
+
|
|
120
|
+
if "expected" not in task:
|
|
121
|
+
errors.append(f"{where}.expected is required")
|
|
122
|
+
elif not _expected_matches_type(task["expected"], ttype):
|
|
123
|
+
errors.append(
|
|
124
|
+
f"{where}.expected does not match type={ttype!r} "
|
|
125
|
+
f"(got {type(task['expected']).__name__})"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if "weight" in task:
|
|
129
|
+
w = task["weight"]
|
|
130
|
+
if not isinstance(w, (int, float)) or isinstance(w, bool) or w <= 0:
|
|
131
|
+
errors.append(f"{where}.weight must be a number > 0, got {w!r}")
|
|
132
|
+
|
|
133
|
+
if "meta" in task and not isinstance(task["meta"], dict):
|
|
134
|
+
errors.append(f"{where}.meta must be an object when present")
|
|
135
|
+
|
|
136
|
+
return (errors, warnings)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def slugify_theme(theme: str) -> str:
|
|
140
|
+
"""``theme`` -> a filesystem-safe pack slug: lowercase, non-alnum runs ->
|
|
141
|
+
``_``, trimmed. Drives the ``packs/<theme>.json`` naming convention
|
|
142
|
+
(spec §6 / ``bench add``) — the pack's OWN header names the file, no
|
|
143
|
+
separate name is ever asked for on the command line."""
|
|
144
|
+
s = re.sub(r"[^a-z0-9]+", "_", theme.strip().lower()).strip("_")
|
|
145
|
+
return s or "pack"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The auto-refreshed LLM LIST-price table (spec §4) — LIFTED from the
|
|
2
|
+
reference lab's already-tested ``workers/llm_prices.py`` (adapted imports/
|
|
3
|
+
cache path only; lookup semantics, fallback table, and substring-order bug
|
|
4
|
+
guard are unchanged). Do NOT reinvent this — see
|
|
5
|
+
``docs/2026-07-24-model-showdown-findings.md`` shipped-alongside note: it
|
|
6
|
+
already caught a real ~5x under-count on gemini-2.5-flash-lite.
|
|
7
|
+
|
|
8
|
+
The LIST-price BASE is auto-refreshed from the community-maintained LiteLLM
|
|
9
|
+
repo (:mod:`swarph_cli.bench.refresh_prices` -> a local JSON cache; TODO wire
|
|
10
|
+
a weekly cron the way the reference lab does), so new models are never
|
|
11
|
+
silently mis-priced by a stale hardcoded bucket.
|
|
12
|
+
|
|
13
|
+
Public API:
|
|
14
|
+
lookup(model) -> (input_$/Mtok, output_$/Mtok) # never raises
|
|
15
|
+
cost_usd(model, tokens_in, tokens_thought, tokens_out) -> $
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("swarph.bench.prices")
|
|
24
|
+
|
|
25
|
+
_CACHE_PATH = os.path.join(os.path.dirname(__file__), "data", "llm_prices.json")
|
|
26
|
+
_MAP: dict | None = None
|
|
27
|
+
|
|
28
|
+
# Reconciled fallbacks ($/Mtok input, output) — used ONLY when a model isn't in
|
|
29
|
+
# the refreshed cache. The cache is the real source; these are the safety net.
|
|
30
|
+
_FALLBACK = {
|
|
31
|
+
"pro": (1.25, 10.0),
|
|
32
|
+
"lite": (0.019, 0.075),
|
|
33
|
+
"flash": (0.30, 2.50),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(force: bool = False) -> dict:
|
|
38
|
+
"""The cached ``{normalized_model: {"in": $/Mtok, "out": $/Mtok}}`` map.
|
|
39
|
+
Cached in-process; ``force=True`` re-reads the file. Never raises."""
|
|
40
|
+
global _MAP
|
|
41
|
+
if _MAP is not None and not force:
|
|
42
|
+
return _MAP
|
|
43
|
+
try:
|
|
44
|
+
with open(_CACHE_PATH) as f:
|
|
45
|
+
_MAP = (json.load(f) or {}).get("prices", {}) or {}
|
|
46
|
+
except Exception as e: # missing/corrupt cache -> fall back to built-ins
|
|
47
|
+
log.debug("llm price cache unavailable (%s) — using fallbacks", e)
|
|
48
|
+
_MAP = {}
|
|
49
|
+
return _MAP
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _norm(model: str) -> str:
|
|
53
|
+
"""Strip provider prefix (gemini/, vertex_ai/, ...) + lowercase."""
|
|
54
|
+
return (model or "").split("/")[-1].strip().lower()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lookup(model: str) -> tuple[float, float]:
|
|
58
|
+
"""(input_$/Mtok, output_$/Mtok) for ``model``. Exact cache hit ->
|
|
59
|
+
substring fallback (lite before flash so 'flash-lite' resolves as lite —
|
|
60
|
+
order-sensitive, don't reorder). Never raises."""
|
|
61
|
+
m = _norm(model)
|
|
62
|
+
prices = load()
|
|
63
|
+
p = prices.get(m)
|
|
64
|
+
if p and "in" in p and "out" in p:
|
|
65
|
+
return (float(p["in"]), float(p["out"]))
|
|
66
|
+
# substring fallback — ORDER MATTERS: lite is a substring of flash-lite
|
|
67
|
+
# and must win over flash.
|
|
68
|
+
if "lite" in m:
|
|
69
|
+
return _FALLBACK["lite"]
|
|
70
|
+
if "pro" in m:
|
|
71
|
+
return _FALLBACK["pro"]
|
|
72
|
+
return _FALLBACK["flash"] # flash + safest default
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_known(model_id: str) -> bool:
|
|
76
|
+
"""True if ``model_id`` has an exact hit in the refreshed cache (as
|
|
77
|
+
opposed to resolving via the substring fallback)."""
|
|
78
|
+
p = load().get(_norm(model_id))
|
|
79
|
+
return bool(p and "in" in p and "out" in p)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cost_usd(model_id: str, tokens_in: int, tokens_thought: int, tokens_out: int) -> float:
|
|
83
|
+
"""Metered $ for one call: in-tokens at the input price, (thought + out)
|
|
84
|
+
tokens at the output price (mirrors the reference ``metered_usd()``)."""
|
|
85
|
+
price_in, price_out = lookup(model_id)
|
|
86
|
+
thought = tokens_thought or 0
|
|
87
|
+
return (tokens_in * price_in + (thought + tokens_out) * price_out) / 1_000_000
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def all_cached(force: bool = False) -> dict[str, tuple[float, float]]:
|
|
91
|
+
"""Every ``{model_id: (in, out)}`` currently in the refreshed cache — the
|
|
92
|
+
full LiteLLM-sourced price list, not just the models ``bench`` happens to
|
|
93
|
+
price. Exposed so the cache is a reusable mesh asset (e.g. a
|
|
94
|
+
cost-governor/ledger can read the same cache via ``swarph bench prices``)
|
|
95
|
+
rather than plumbing private to the runner."""
|
|
96
|
+
return {m: (float(v["in"]), float(v["out"])) for m, v in load(force=force).items()
|
|
97
|
+
if isinstance(v, dict) and "in" in v and "out" in v}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fallback_buckets() -> dict[str, tuple[float, float]]:
|
|
101
|
+
"""The 3 built-in substring-fallback buckets (pro/lite/flash), for
|
|
102
|
+
display when the cache is empty/stale."""
|
|
103
|
+
return dict(_FALLBACK)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The FIXED distance engine — ported VERBATIM (math unchanged) from the
|
|
2
|
+
reference ``bench_quality.py`` (``d_numeric/d_categorical/d_ranking/d_text``,
|
|
3
|
+
``parse_answer``, ``score``). See spec §2.
|
|
4
|
+
|
|
5
|
+
Every score is a DISTANCE in [0, 1] where 0 = perfect. The engine is fixed and
|
|
6
|
+
domain-agnostic — a pack never ships code, only data (``system`` + tasks +
|
|
7
|
+
expected answers); this module is the ONLY place distance math lives.
|
|
8
|
+
|
|
9
|
+
**Parse-fail -> distance = 1.0** is a load-bearing rule, not an edge case: a
|
|
10
|
+
model that can't follow the structured-output instruction gets the worst
|
|
11
|
+
possible score, because that IS a real quality signal (findings.md).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
# ---- distance functions (0 = exact, 1 = worst) -----------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def d_numeric(provided: Any, expected: Any) -> float:
|
|
23
|
+
"""Capped relative error. ``e == 0`` is a special case: 0.0 if ``p == 0``
|
|
24
|
+
else 1.0 (relative error is undefined at zero)."""
|
|
25
|
+
try:
|
|
26
|
+
p, e = float(provided), float(expected)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return 1.0
|
|
29
|
+
if e == 0:
|
|
30
|
+
return 0.0 if p == 0 else 1.0
|
|
31
|
+
return min(abs(p - e) / abs(e), 1.0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def d_categorical(provided: Any, expected: Any) -> float:
|
|
35
|
+
"""0 if ``upper(strip(provided)) == upper(strip(expected))`` else 1."""
|
|
36
|
+
return 0.0 if str(provided).strip().upper() == str(expected).strip().upper() else 1.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def d_ranking(provided: Any, expected: Any) -> float:
|
|
40
|
+
"""Normalized Kendall-tau distance on the ranked list (0 = same order,
|
|
41
|
+
1 = fully reversed). Wrong item SET (not a list, or items don't match the
|
|
42
|
+
expected set) -> 1.0 (worst), per spec §2."""
|
|
43
|
+
if not isinstance(provided, list) or sorted(map(str, provided)) != sorted(map(str, expected)):
|
|
44
|
+
return 1.0
|
|
45
|
+
pos = {str(x): i for i, x in enumerate(provided)}
|
|
46
|
+
exp = [str(x) for x in expected]
|
|
47
|
+
n = len(exp)
|
|
48
|
+
if n < 2:
|
|
49
|
+
return 0.0
|
|
50
|
+
discord = sum(1 for i in range(n) for j in range(i + 1, n) if pos[exp[i]] > pos[exp[j]])
|
|
51
|
+
return discord / (n * (n - 1) / 2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def d_text(provided: Any, expected: Any, embedder: Optional[Callable[[str], Any]] = None) -> float:
|
|
55
|
+
"""Free-text distance: ``1 - Jaccard(tokens(p), tokens(e))`` — a cheap, $0,
|
|
56
|
+
deterministic proxy with no external dependency (v1 default, spec §2 /
|
|
57
|
+
decision #2).
|
|
58
|
+
|
|
59
|
+
``embedder`` is a clearly-marked SEAM for an optional embedding-cosine
|
|
60
|
+
distance (e.g. gbrain's $0 embeddings) — NOT wired in v1. When provided it
|
|
61
|
+
must be a ``str -> vector`` callable; cosine distance replaces Jaccard.
|
|
62
|
+
Left unimplemented deliberately: gbrain is not coupled here yet.
|
|
63
|
+
"""
|
|
64
|
+
if embedder is not None:
|
|
65
|
+
raise NotImplementedError(
|
|
66
|
+
"d_text(embedder=...) is a seam for a future gbrain-cosine embedder; "
|
|
67
|
+
"not wired in v1 — call d_text(provided, expected) without embedder."
|
|
68
|
+
)
|
|
69
|
+
pa = set(str(provided).lower().split())
|
|
70
|
+
ea = set(str(expected).lower().split())
|
|
71
|
+
if not ea:
|
|
72
|
+
return 0.0 if not pa else 1.0
|
|
73
|
+
union = len(pa | ea)
|
|
74
|
+
return 1.0 - (len(pa & ea) / union if union else 0.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
DISTANCE: dict[str, Callable[[Any, Any], float]] = {
|
|
78
|
+
"numeric": d_numeric,
|
|
79
|
+
"categorical": d_categorical,
|
|
80
|
+
"ranking": d_ranking,
|
|
81
|
+
"text": d_text,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---- answer extraction ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_answer(text: Optional[str]) -> Any:
|
|
89
|
+
"""Pull the LAST ``{"answer": ...}`` JSON object from a model reply
|
|
90
|
+
(tolerant of code fences / surrounding prose). Returns ``None`` on parse
|
|
91
|
+
failure — the caller (``score``) turns that into distance=1.0."""
|
|
92
|
+
if not text:
|
|
93
|
+
return None
|
|
94
|
+
last = None
|
|
95
|
+
found = False
|
|
96
|
+
for m in re.finditer(r'\{[^{}]*"answer"[^{}]*\}', text, re.DOTALL):
|
|
97
|
+
try:
|
|
98
|
+
last = json.loads(m.group(0)).get("answer")
|
|
99
|
+
found = True
|
|
100
|
+
except Exception:
|
|
101
|
+
continue
|
|
102
|
+
return last if found else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def score(task: dict, text: str) -> dict:
|
|
106
|
+
"""-> ``{distance, parsed, parse_ok}``.
|
|
107
|
+
|
|
108
|
+
``numeric``/``categorical``/``ranking`` are parsed from the structured
|
|
109
|
+
``{"answer": ...}`` JSON; ``text`` scores the raw response verbatim.
|
|
110
|
+
Unparseable structured answers are the worst score (1.0) — a real quality
|
|
111
|
+
signal, not a bug (spec §2).
|
|
112
|
+
"""
|
|
113
|
+
ttype = task["type"]
|
|
114
|
+
if ttype == "text":
|
|
115
|
+
ans = text or ""
|
|
116
|
+
dist = d_text(ans, task["expected"])
|
|
117
|
+
return {"distance": round(dist, 4), "parsed": ans, "parse_ok": True}
|
|
118
|
+
ans = parse_answer(text)
|
|
119
|
+
if ans is None:
|
|
120
|
+
return {"distance": 1.0, "parsed": None, "parse_ok": False}
|
|
121
|
+
dist = DISTANCE[ttype](ans, task["expected"])
|
|
122
|
+
return {"distance": round(dist, 4), "parsed": ans, "parse_ok": True}
|