specsmith 0.7.0.dev235__tar.gz → 0.8.0.dev237__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.
- {specsmith-0.7.0.dev235/src/specsmith.egg-info → specsmith-0.8.0.dev237}/PKG-INFO +1 -1
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/pyproject.toml +1 -1
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/chat_runner.py +98 -1
- specsmith-0.8.0.dev237/src/specsmith/agent/endpoints.py +493 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/cli.py +373 -2
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/SOURCES.txt +4 -0
- specsmith-0.8.0.dev237/tests/test_chat_runner_openai_compat.py +195 -0
- specsmith-0.8.0.dev237/tests/test_endpoints_cli.py +244 -0
- specsmith-0.8.0.dev237/tests/test_endpoints_store.py +350 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/LICENSE +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/README.md +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/setup.cfg +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/belief.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/certainty.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/failure_graph.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/py.typed +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/recovery.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/session.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/stress_tester.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/epistemic/trace.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/__main__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/broker.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/cleanup.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/events.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/indexer.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/mcp.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/memory.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/orchestrator.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/repl.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/router.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/rules.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/safety.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/suggester.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/tools.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/verifier.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/agent/voice.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/architect.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/auditor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/auth.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/block_export.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/cloud_serve.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/compressor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/config.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/console_utils.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/credit_analyzer.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/credits.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/differ.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/doctor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/drive.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/belief.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/certainty.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/failure_graph.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/recovery.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/stress_tester.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/executor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/exporter.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/app.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/main_window.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/session_tab.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/theme.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/chat_view.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/input_bar.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/provider_bar.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/token_meter.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/tool_panel.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/update_checker.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/gui/worker.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/history_search.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/importer.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/agent_skill.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/languages.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/ledger.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/patent.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/phase.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/plugins.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/profiles.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/rate_limits.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/releaser.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/requirements.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/requirements_parser.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/retrieval.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/scaffolder.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/serve.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/session.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/skills.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/go/go.mod.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/go/main.go.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/js/package.json.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/rust/main.rs.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/tool_installer.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/toolrules.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/tools.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/trace.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/updater.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/validator.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/wireframes.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith/workspace.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_CMD_001.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_auditor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_chat_diff_decision.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_chat_stdin_protocol.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_cli.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_cli_workflows_history_drive.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_compressor.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_e2e_nexus.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_epistemic.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_importer.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_integrations.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_mcp_client.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_nexus.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_phase1_4_new.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_phase34_completion.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_rate_limits.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_scaffolder.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_skill_marketplace.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_smoke.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_suggester.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_tools.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_validator.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_vcs.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_warp_parity.py +0 -0
- {specsmith-0.7.0.dev235 → specsmith-0.8.0.dev237}/tests/test_warp_parity_followup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: specsmith
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0.dev237
|
|
4
4
|
Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
|
|
5
5
|
Author: BitConcepts
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specsmith"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0.dev237"
|
|
8
8
|
description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -80,11 +80,35 @@ def run_chat(
|
|
|
80
80
|
history: list[dict[str, Any]] | None = None,
|
|
81
81
|
confidence_target: float = 0.7,
|
|
82
82
|
rules_prefix: str = "",
|
|
83
|
+
endpoint_id: str | None = None,
|
|
83
84
|
) -> ChatRunResult | None:
|
|
84
|
-
"""Drive a real LLM turn. Return ``None`` if no provider is reachable.
|
|
85
|
+
"""Drive a real LLM turn. Return ``None`` if no provider is reachable.
|
|
86
|
+
|
|
87
|
+
When ``endpoint_id`` is set, the BYOE store (REQ-142) is consulted and
|
|
88
|
+
the resolved :class:`Endpoint` short-circuits the provider chain via
|
|
89
|
+
the new :func:`_run_openai_compat` driver. Any error during endpoint
|
|
90
|
+
resolution falls back to the legacy auto-detect chain so an offline
|
|
91
|
+
misconfigured endpoint never breaks `specsmith chat`.
|
|
92
|
+
"""
|
|
85
93
|
history = history or []
|
|
86
94
|
messages = _build_messages(utterance, history, rules_prefix)
|
|
87
95
|
|
|
96
|
+
# REQ-142: explicit endpoint override.
|
|
97
|
+
if endpoint_id:
|
|
98
|
+
try:
|
|
99
|
+
from specsmith.agent.endpoints import EndpointStore
|
|
100
|
+
|
|
101
|
+
endpoint = EndpointStore.load().resolve(endpoint_id)
|
|
102
|
+
except Exception: # noqa: BLE001 - any failure → fall back to auto-detect
|
|
103
|
+
endpoint = None
|
|
104
|
+
if endpoint is not None:
|
|
105
|
+
try:
|
|
106
|
+
full_text = _run_openai_compat(messages, emitter, msg_block, endpoint=endpoint)
|
|
107
|
+
except Exception: # noqa: BLE001 - degrade to auto-detect
|
|
108
|
+
full_text = None
|
|
109
|
+
if full_text is not None:
|
|
110
|
+
return _finalize(full_text, "openai_compat", project_dir, confidence_target)
|
|
111
|
+
|
|
88
112
|
# Order matters: Ollama first because it's local-first and free.
|
|
89
113
|
for provider in (_run_ollama, _run_anthropic, _run_openai, _run_gemini):
|
|
90
114
|
try:
|
|
@@ -228,6 +252,79 @@ def _run_openai(
|
|
|
228
252
|
return "".join(pieces) if pieces else None
|
|
229
253
|
|
|
230
254
|
|
|
255
|
+
def _run_openai_compat(
|
|
256
|
+
messages: list[dict[str, str]],
|
|
257
|
+
emitter: EventEmitter,
|
|
258
|
+
block_id: str,
|
|
259
|
+
*,
|
|
260
|
+
endpoint: Any,
|
|
261
|
+
) -> str | None:
|
|
262
|
+
"""Stream from a user-registered OpenAI-v1-compatible endpoint (REQ-142).
|
|
263
|
+
|
|
264
|
+
Uses raw stdlib HTTP so the openai SDK is not a hard dependency for
|
|
265
|
+
BYOE. Sends a streaming ``/chat/completions`` request, decodes the
|
|
266
|
+
Server-Sent-Events ``data:`` lines, and forwards each ``content``
|
|
267
|
+
delta as a ``token`` event on ``block_id``.
|
|
268
|
+
"""
|
|
269
|
+
base_url = endpoint.base_url.rstrip("/")
|
|
270
|
+
url = f"{base_url}/chat/completions"
|
|
271
|
+
model = endpoint.default_model or os.environ.get("SPECSMITH_OPENAI_COMPAT_MODEL", "")
|
|
272
|
+
if not model:
|
|
273
|
+
# The endpoint did not pin a default model and the env override is
|
|
274
|
+
# absent. We cannot fabricate one; fall back to the auto-detect chain.
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
headers: dict[str, str] = {
|
|
278
|
+
"Content-Type": "application/json",
|
|
279
|
+
"Accept": "text/event-stream",
|
|
280
|
+
}
|
|
281
|
+
try:
|
|
282
|
+
token = endpoint.resolve_token()
|
|
283
|
+
except Exception: # noqa: BLE001 - fall back to auto-detect chain
|
|
284
|
+
return None
|
|
285
|
+
if token:
|
|
286
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
287
|
+
|
|
288
|
+
body = json.dumps({"model": model, "messages": messages, "stream": True}).encode("utf-8")
|
|
289
|
+
req = Request(url, data=body, headers=headers, method="POST") # noqa: S310 - user-supplied
|
|
290
|
+
|
|
291
|
+
ctx = None
|
|
292
|
+
if not endpoint.verify_tls and url.startswith("https://"):
|
|
293
|
+
import ssl
|
|
294
|
+
|
|
295
|
+
ctx = ssl.create_default_context()
|
|
296
|
+
ctx.check_hostname = False
|
|
297
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
298
|
+
|
|
299
|
+
pieces: list[str] = []
|
|
300
|
+
try:
|
|
301
|
+
with urlopen(req, timeout=120, context=ctx) as resp: # noqa: S310 - user-supplied
|
|
302
|
+
for raw_line in resp:
|
|
303
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
304
|
+
if not line.startswith("data:"):
|
|
305
|
+
continue
|
|
306
|
+
payload = line[len("data:") :].strip()
|
|
307
|
+
if not payload or payload == "[DONE]":
|
|
308
|
+
if payload == "[DONE]":
|
|
309
|
+
break
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
obj = json.loads(payload)
|
|
313
|
+
except ValueError:
|
|
314
|
+
continue
|
|
315
|
+
choices = obj.get("choices") or []
|
|
316
|
+
if not choices:
|
|
317
|
+
continue
|
|
318
|
+
delta = (choices[0] or {}).get("delta") or {}
|
|
319
|
+
chunk = str(delta.get("content") or "")
|
|
320
|
+
if chunk:
|
|
321
|
+
emitter.token(block_id, chunk)
|
|
322
|
+
pieces.append(chunk)
|
|
323
|
+
except (URLError, TimeoutError, OSError):
|
|
324
|
+
return None
|
|
325
|
+
return "".join(pieces) if pieces else None
|
|
326
|
+
|
|
327
|
+
|
|
231
328
|
def _run_gemini(
|
|
232
329
|
messages: list[dict[str, str]],
|
|
233
330
|
emitter: EventEmitter,
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Bring-Your-Own-Endpoint (BYOE) data model and persistence (REQ-142).
|
|
4
|
+
|
|
5
|
+
Specsmith historically hard-coded a closed provider list (``ollama`` /
|
|
6
|
+
``anthropic`` / ``openai`` / ``gemini`` / ``mistral``). This module
|
|
7
|
+
introduces a generic OpenAI-v1-compatible endpoint store so users can
|
|
8
|
+
register self-hosted vLLM, llama.cpp ``server``, LM Studio, TGI, or any
|
|
9
|
+
other ``/v1/chat/completions``-shaped backend and pick between several
|
|
10
|
+
side-by-side.
|
|
11
|
+
|
|
12
|
+
Storage layout (``~/.specsmith/endpoints.json``):
|
|
13
|
+
|
|
14
|
+
.. code-block:: json
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"schema_version": 1,
|
|
18
|
+
"default_endpoint_id": "home-vllm",
|
|
19
|
+
"endpoints": [
|
|
20
|
+
{
|
|
21
|
+
"id": "home-vllm",
|
|
22
|
+
"name": "Home vLLM",
|
|
23
|
+
"base_url": "http://10.0.0.4:8000/v1",
|
|
24
|
+
"auth": {"kind": "bearer-keyring",
|
|
25
|
+
"keyring_service": "specsmith",
|
|
26
|
+
"keyring_user": "endpoint:home-vllm"},
|
|
27
|
+
"default_model": "Qwen/Qwen2.5-Coder-32B",
|
|
28
|
+
"verify_tls": true,
|
|
29
|
+
"tags": ["local", "coder"],
|
|
30
|
+
"created_at": "2026-05-01T11:30:17Z"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Tokens are NEVER printed verbatim by anything in this module; ``list_all``
|
|
36
|
+
serialisation routes through :func:`Endpoint.to_public_dict` which
|
|
37
|
+
redacts inline tokens to ``"***"``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import time
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
SCHEMA_VERSION = 1
|
|
50
|
+
DEFAULT_KEYRING_SERVICE = "specsmith"
|
|
51
|
+
|
|
52
|
+
VALID_AUTH_KINDS = ("none", "bearer-inline", "bearer-env", "bearer-keyring")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EndpointError(RuntimeError):
|
|
56
|
+
"""Raised for user-facing endpoint errors (validation, missing token, ...)."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Data model
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class EndpointAuth:
|
|
66
|
+
"""Discriminated-union auth metadata.
|
|
67
|
+
|
|
68
|
+
``kind`` is one of:
|
|
69
|
+
|
|
70
|
+
* ``none`` — no Authorization header (e.g. open vLLM on a trusted LAN).
|
|
71
|
+
* ``bearer-inline`` — token stored verbatim in ``endpoints.json``.
|
|
72
|
+
Only used when the user explicitly opts in; the on-disk plaintext
|
|
73
|
+
is documented as insecure.
|
|
74
|
+
* ``bearer-env`` — token resolved from ``token_env`` at call time.
|
|
75
|
+
* ``bearer-keyring`` — token stored in the OS keyring under
|
|
76
|
+
``(keyring_service, keyring_user)``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
kind: str = "none"
|
|
80
|
+
token: str = "" # only set when kind == "bearer-inline"
|
|
81
|
+
token_env: str = "" # only set when kind == "bearer-env"
|
|
82
|
+
keyring_service: str = DEFAULT_KEYRING_SERVICE
|
|
83
|
+
keyring_user: str = ""
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
"""On-disk shape (token included for ``bearer-inline``)."""
|
|
87
|
+
out: dict[str, Any] = {"kind": self.kind}
|
|
88
|
+
if self.kind == "bearer-inline":
|
|
89
|
+
out["token"] = self.token
|
|
90
|
+
elif self.kind == "bearer-env":
|
|
91
|
+
out["token_env"] = self.token_env
|
|
92
|
+
elif self.kind == "bearer-keyring":
|
|
93
|
+
out["keyring_service"] = self.keyring_service
|
|
94
|
+
out["keyring_user"] = self.keyring_user
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
def to_public_dict(self) -> dict[str, Any]:
|
|
98
|
+
"""Redacted shape — never returns inline token bytes."""
|
|
99
|
+
out: dict[str, Any] = {"kind": self.kind}
|
|
100
|
+
if self.kind == "bearer-inline":
|
|
101
|
+
out["token"] = "***"
|
|
102
|
+
elif self.kind == "bearer-env":
|
|
103
|
+
out["token_env"] = self.token_env
|
|
104
|
+
elif self.kind == "bearer-keyring":
|
|
105
|
+
out["keyring_service"] = self.keyring_service
|
|
106
|
+
out["keyring_user"] = self.keyring_user
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, raw: dict[str, Any]) -> EndpointAuth:
|
|
111
|
+
kind = str(raw.get("kind") or "none").strip()
|
|
112
|
+
if kind not in VALID_AUTH_KINDS:
|
|
113
|
+
raise EndpointError(f"invalid auth kind {kind!r}; expected one of {VALID_AUTH_KINDS}")
|
|
114
|
+
return cls(
|
|
115
|
+
kind=kind,
|
|
116
|
+
token=str(raw.get("token") or ""),
|
|
117
|
+
token_env=str(raw.get("token_env") or ""),
|
|
118
|
+
keyring_service=str(raw.get("keyring_service") or DEFAULT_KEYRING_SERVICE),
|
|
119
|
+
keyring_user=str(raw.get("keyring_user") or ""),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Endpoint:
|
|
125
|
+
"""A single OpenAI-v1-compatible endpoint registered for use with specsmith."""
|
|
126
|
+
|
|
127
|
+
id: str
|
|
128
|
+
name: str
|
|
129
|
+
base_url: str
|
|
130
|
+
auth: EndpointAuth = field(default_factory=EndpointAuth)
|
|
131
|
+
default_model: str = ""
|
|
132
|
+
verify_tls: bool = True
|
|
133
|
+
tags: list[str] = field(default_factory=list)
|
|
134
|
+
created_at: str = ""
|
|
135
|
+
|
|
136
|
+
# ── Validation ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def validate(self) -> None:
|
|
139
|
+
"""Raise :class:`EndpointError` on structural problems."""
|
|
140
|
+
if not self.id or not self.id.strip():
|
|
141
|
+
raise EndpointError("endpoint id must be non-empty")
|
|
142
|
+
if any(c.isspace() for c in self.id):
|
|
143
|
+
raise EndpointError(f"endpoint id {self.id!r} must not contain whitespace")
|
|
144
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
145
|
+
raise EndpointError(
|
|
146
|
+
f"endpoint base_url {self.base_url!r} must start with http:// or https://"
|
|
147
|
+
)
|
|
148
|
+
if self.auth.kind == "bearer-env" and not self.auth.token_env:
|
|
149
|
+
raise EndpointError("auth.kind == 'bearer-env' requires a non-empty token_env")
|
|
150
|
+
if self.auth.kind == "bearer-keyring" and not self.auth.keyring_user:
|
|
151
|
+
raise EndpointError(
|
|
152
|
+
"auth.kind == 'bearer-keyring' requires a keyring_user (defaults to endpoint:<id>)"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ── Token resolution ───────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def resolve_token(self) -> str | None:
|
|
158
|
+
"""Return the bearer token for this endpoint, or ``None`` for unauthenticated.
|
|
159
|
+
|
|
160
|
+
Order of resolution mirrors :data:`EndpointAuth.kind`. Errors are
|
|
161
|
+
converted to :class:`EndpointError` so callers can surface a clean
|
|
162
|
+
message instead of a stack trace.
|
|
163
|
+
"""
|
|
164
|
+
kind = self.auth.kind
|
|
165
|
+
if kind == "none":
|
|
166
|
+
return None
|
|
167
|
+
if kind == "bearer-inline":
|
|
168
|
+
return self.auth.token or None
|
|
169
|
+
if kind == "bearer-env":
|
|
170
|
+
value = os.environ.get(self.auth.token_env, "").strip()
|
|
171
|
+
if not value:
|
|
172
|
+
raise EndpointError(
|
|
173
|
+
f"endpoint {self.id!r} expects token in env var "
|
|
174
|
+
f"{self.auth.token_env!r}, but it is unset"
|
|
175
|
+
)
|
|
176
|
+
return value
|
|
177
|
+
if kind == "bearer-keyring":
|
|
178
|
+
try:
|
|
179
|
+
import keyring
|
|
180
|
+
except Exception as exc: # noqa: BLE001
|
|
181
|
+
raise EndpointError(
|
|
182
|
+
"keyring is not available — install python-keyring or "
|
|
183
|
+
"switch the endpoint to --auth bearer-env"
|
|
184
|
+
) from exc
|
|
185
|
+
try:
|
|
186
|
+
value = keyring.get_password(self.auth.keyring_service, self.auth.keyring_user)
|
|
187
|
+
except Exception as exc: # noqa: BLE001
|
|
188
|
+
raise EndpointError(f"keyring lookup failed: {exc}") from exc
|
|
189
|
+
if not value:
|
|
190
|
+
raise EndpointError(
|
|
191
|
+
f"endpoint {self.id!r} has no token stored in keyring "
|
|
192
|
+
f"({self.auth.keyring_service}/{self.auth.keyring_user})"
|
|
193
|
+
)
|
|
194
|
+
return str(value)
|
|
195
|
+
raise EndpointError(f"unknown auth kind {kind!r}")
|
|
196
|
+
|
|
197
|
+
# ── Health / discovery ─────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def health(self, *, timeout: float = 5.0) -> EndpointHealth:
|
|
200
|
+
"""Probe ``<base_url>/models`` and return a structured result.
|
|
201
|
+
|
|
202
|
+
Network and HTTP errors are caught — the returned record always has
|
|
203
|
+
``ok`` populated. ``models`` is empty when the endpoint does not
|
|
204
|
+
expose ``/models``; that is not an error in itself.
|
|
205
|
+
"""
|
|
206
|
+
import urllib.error
|
|
207
|
+
import urllib.request
|
|
208
|
+
|
|
209
|
+
url = self.base_url.rstrip("/") + "/models"
|
|
210
|
+
req = urllib.request.Request(url) # noqa: S310 - user-supplied
|
|
211
|
+
try:
|
|
212
|
+
token = self.resolve_token()
|
|
213
|
+
except EndpointError as exc:
|
|
214
|
+
return EndpointHealth(
|
|
215
|
+
ok=False, latency_ms=0.0, models=[], error=str(exc), status_code=None
|
|
216
|
+
)
|
|
217
|
+
if token:
|
|
218
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
219
|
+
start = time.perf_counter()
|
|
220
|
+
try:
|
|
221
|
+
ctx = None
|
|
222
|
+
if not self.verify_tls and url.startswith("https://"):
|
|
223
|
+
import ssl
|
|
224
|
+
|
|
225
|
+
ctx = ssl.create_default_context()
|
|
226
|
+
ctx.check_hostname = False
|
|
227
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
228
|
+
with urllib.request.urlopen( # noqa: S310 - user-supplied
|
|
229
|
+
req, timeout=timeout, context=ctx
|
|
230
|
+
) as resp:
|
|
231
|
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
|
232
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
233
|
+
models = _extract_model_ids(payload)
|
|
234
|
+
return EndpointHealth(
|
|
235
|
+
ok=True,
|
|
236
|
+
latency_ms=latency_ms,
|
|
237
|
+
models=models,
|
|
238
|
+
error="",
|
|
239
|
+
status_code=int(resp.status),
|
|
240
|
+
)
|
|
241
|
+
except urllib.error.HTTPError as exc:
|
|
242
|
+
return EndpointHealth(
|
|
243
|
+
ok=False,
|
|
244
|
+
latency_ms=(time.perf_counter() - start) * 1000.0,
|
|
245
|
+
models=[],
|
|
246
|
+
error=f"HTTP {exc.code}",
|
|
247
|
+
status_code=int(exc.code),
|
|
248
|
+
)
|
|
249
|
+
except Exception as exc: # noqa: BLE001
|
|
250
|
+
return EndpointHealth(
|
|
251
|
+
ok=False,
|
|
252
|
+
latency_ms=(time.perf_counter() - start) * 1000.0,
|
|
253
|
+
models=[],
|
|
254
|
+
error=str(exc),
|
|
255
|
+
status_code=None,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# ── Serialisation ──────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def to_dict(self) -> dict[str, Any]:
|
|
261
|
+
return {
|
|
262
|
+
"id": self.id,
|
|
263
|
+
"name": self.name,
|
|
264
|
+
"base_url": self.base_url,
|
|
265
|
+
"auth": self.auth.to_dict(),
|
|
266
|
+
"default_model": self.default_model,
|
|
267
|
+
"verify_tls": bool(self.verify_tls),
|
|
268
|
+
"tags": list(self.tags),
|
|
269
|
+
"created_at": self.created_at,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def to_public_dict(self) -> dict[str, Any]:
|
|
273
|
+
return {
|
|
274
|
+
"id": self.id,
|
|
275
|
+
"name": self.name,
|
|
276
|
+
"base_url": self.base_url,
|
|
277
|
+
"auth": self.auth.to_public_dict(),
|
|
278
|
+
"default_model": self.default_model,
|
|
279
|
+
"verify_tls": bool(self.verify_tls),
|
|
280
|
+
"tags": list(self.tags),
|
|
281
|
+
"created_at": self.created_at,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_dict(cls, raw: dict[str, Any]) -> Endpoint:
|
|
286
|
+
return cls(
|
|
287
|
+
id=str(raw.get("id") or "").strip(),
|
|
288
|
+
name=str(raw.get("name") or "").strip(),
|
|
289
|
+
base_url=str(raw.get("base_url") or "").strip(),
|
|
290
|
+
auth=EndpointAuth.from_dict(raw.get("auth") or {}),
|
|
291
|
+
default_model=str(raw.get("default_model") or "").strip(),
|
|
292
|
+
verify_tls=bool(raw.get("verify_tls", True)),
|
|
293
|
+
tags=[str(t) for t in (raw.get("tags") or [])],
|
|
294
|
+
created_at=str(raw.get("created_at") or ""),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class EndpointHealth:
|
|
300
|
+
"""Structured result of :meth:`Endpoint.health`."""
|
|
301
|
+
|
|
302
|
+
ok: bool
|
|
303
|
+
latency_ms: float
|
|
304
|
+
models: list[str]
|
|
305
|
+
error: str = ""
|
|
306
|
+
status_code: int | None = None
|
|
307
|
+
|
|
308
|
+
def to_dict(self) -> dict[str, Any]:
|
|
309
|
+
return {
|
|
310
|
+
"ok": self.ok,
|
|
311
|
+
"latency_ms": round(self.latency_ms, 2),
|
|
312
|
+
"models": list(self.models),
|
|
313
|
+
"error": self.error,
|
|
314
|
+
"status_code": self.status_code,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _extract_model_ids(payload: Any) -> list[str]:
|
|
319
|
+
"""Pull a list of model id strings out of an OpenAI ``/v1/models`` body.
|
|
320
|
+
|
|
321
|
+
Tolerates the two common shapes (``{"data": [{"id": ...}]}`` from real
|
|
322
|
+
OpenAI / vLLM and ``{"models": [...]}`` used by some proxies).
|
|
323
|
+
"""
|
|
324
|
+
out: list[str] = []
|
|
325
|
+
if isinstance(payload, dict):
|
|
326
|
+
candidates = payload.get("data") or payload.get("models") or []
|
|
327
|
+
if isinstance(candidates, list):
|
|
328
|
+
for item in candidates:
|
|
329
|
+
if isinstance(item, dict) and "id" in item:
|
|
330
|
+
out.append(str(item["id"]))
|
|
331
|
+
elif isinstance(item, str):
|
|
332
|
+
out.append(item)
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Store
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def default_store_path() -> Path:
|
|
342
|
+
"""Resolve ``~/.specsmith/endpoints.json``, honouring ``SPECSMITH_HOME``."""
|
|
343
|
+
base = os.environ.get("SPECSMITH_HOME", "").strip()
|
|
344
|
+
home = Path(base) if base else Path.home() / ".specsmith"
|
|
345
|
+
return home / "endpoints.json"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass
|
|
349
|
+
class EndpointStore:
|
|
350
|
+
"""Read/write wrapper around ``~/.specsmith/endpoints.json``.
|
|
351
|
+
|
|
352
|
+
Tokens are never logged. Inline tokens (``auth.kind == "bearer-inline"``)
|
|
353
|
+
land in the JSON unchanged, but :meth:`list_public` redacts them. The
|
|
354
|
+
keyring-backed and env-backed paths never store secrets in the JSON at
|
|
355
|
+
all.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
path: Path
|
|
359
|
+
schema_version: int = SCHEMA_VERSION
|
|
360
|
+
default_endpoint_id: str = ""
|
|
361
|
+
endpoints: list[Endpoint] = field(default_factory=list)
|
|
362
|
+
|
|
363
|
+
# ── I/O ────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def load(cls, path: Path | None = None) -> EndpointStore:
|
|
367
|
+
target = path or default_store_path()
|
|
368
|
+
if not target.exists():
|
|
369
|
+
return cls(path=target)
|
|
370
|
+
try:
|
|
371
|
+
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
372
|
+
except json.JSONDecodeError as exc:
|
|
373
|
+
raise EndpointError(
|
|
374
|
+
f"endpoints store at {target} is corrupted: {exc}. "
|
|
375
|
+
"Move it aside or fix the JSON to continue."
|
|
376
|
+
) from exc
|
|
377
|
+
if not isinstance(raw, dict):
|
|
378
|
+
raise EndpointError(f"endpoints store at {target} must be a JSON object")
|
|
379
|
+
version = int(raw.get("schema_version") or 0)
|
|
380
|
+
if version != SCHEMA_VERSION:
|
|
381
|
+
raise EndpointError(
|
|
382
|
+
f"endpoints store at {target} uses schema_version={version}; "
|
|
383
|
+
f"this build of specsmith only understands {SCHEMA_VERSION}."
|
|
384
|
+
)
|
|
385
|
+
endpoints_raw = raw.get("endpoints") or []
|
|
386
|
+
if not isinstance(endpoints_raw, list):
|
|
387
|
+
raise EndpointError("endpoints store: 'endpoints' must be a list")
|
|
388
|
+
endpoints = [Endpoint.from_dict(item) for item in endpoints_raw]
|
|
389
|
+
return cls(
|
|
390
|
+
path=target,
|
|
391
|
+
schema_version=version,
|
|
392
|
+
default_endpoint_id=str(raw.get("default_endpoint_id") or ""),
|
|
393
|
+
endpoints=endpoints,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def save(self) -> None:
|
|
397
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
payload = {
|
|
399
|
+
"schema_version": self.schema_version,
|
|
400
|
+
"default_endpoint_id": self.default_endpoint_id,
|
|
401
|
+
"endpoints": [e.to_dict() for e in self.endpoints],
|
|
402
|
+
}
|
|
403
|
+
self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
404
|
+
# Best-effort lock-down on POSIX
|
|
405
|
+
import contextlib
|
|
406
|
+
|
|
407
|
+
with contextlib.suppress(Exception):
|
|
408
|
+
self.path.chmod(0o600)
|
|
409
|
+
|
|
410
|
+
# ── CRUD ───────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def add(self, endpoint: Endpoint, *, replace: bool = False) -> None:
|
|
413
|
+
endpoint.validate()
|
|
414
|
+
if not endpoint.created_at:
|
|
415
|
+
endpoint.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
416
|
+
existing = self._index(endpoint.id)
|
|
417
|
+
if existing is not None:
|
|
418
|
+
if not replace:
|
|
419
|
+
raise EndpointError(
|
|
420
|
+
f"endpoint {endpoint.id!r} already exists. Use --replace to overwrite."
|
|
421
|
+
)
|
|
422
|
+
self.endpoints[existing] = endpoint
|
|
423
|
+
else:
|
|
424
|
+
self.endpoints.append(endpoint)
|
|
425
|
+
if not self.default_endpoint_id:
|
|
426
|
+
self.default_endpoint_id = endpoint.id
|
|
427
|
+
|
|
428
|
+
def remove(self, endpoint_id: str) -> bool:
|
|
429
|
+
idx = self._index(endpoint_id)
|
|
430
|
+
if idx is None:
|
|
431
|
+
return False
|
|
432
|
+
self.endpoints.pop(idx)
|
|
433
|
+
if self.default_endpoint_id == endpoint_id:
|
|
434
|
+
self.default_endpoint_id = self.endpoints[0].id if self.endpoints else ""
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
def get(self, endpoint_id: str) -> Endpoint:
|
|
438
|
+
idx = self._index(endpoint_id)
|
|
439
|
+
if idx is None:
|
|
440
|
+
raise EndpointError(f"unknown endpoint id {endpoint_id!r}")
|
|
441
|
+
return self.endpoints[idx]
|
|
442
|
+
|
|
443
|
+
def get_default(self) -> Endpoint | None:
|
|
444
|
+
if not self.default_endpoint_id:
|
|
445
|
+
return None
|
|
446
|
+
idx = self._index(self.default_endpoint_id)
|
|
447
|
+
if idx is None:
|
|
448
|
+
return None
|
|
449
|
+
return self.endpoints[idx]
|
|
450
|
+
|
|
451
|
+
def set_default(self, endpoint_id: str) -> None:
|
|
452
|
+
if self._index(endpoint_id) is None:
|
|
453
|
+
raise EndpointError(f"unknown endpoint id {endpoint_id!r}")
|
|
454
|
+
self.default_endpoint_id = endpoint_id
|
|
455
|
+
|
|
456
|
+
def list_all(self) -> list[Endpoint]:
|
|
457
|
+
return list(self.endpoints)
|
|
458
|
+
|
|
459
|
+
def list_public(self) -> list[dict[str, Any]]:
|
|
460
|
+
return [e.to_public_dict() for e in self.endpoints]
|
|
461
|
+
|
|
462
|
+
def resolve(self, endpoint_id: str | None) -> Endpoint:
|
|
463
|
+
"""Return the named endpoint, or the default if ``endpoint_id`` is empty."""
|
|
464
|
+
if endpoint_id:
|
|
465
|
+
return self.get(endpoint_id)
|
|
466
|
+
default = self.get_default()
|
|
467
|
+
if default is None:
|
|
468
|
+
raise EndpointError(
|
|
469
|
+
"no endpoint specified and no default is set. "
|
|
470
|
+
"Run `specsmith endpoints add ...` to register one."
|
|
471
|
+
)
|
|
472
|
+
return default
|
|
473
|
+
|
|
474
|
+
# ── Internals ──────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
def _index(self, endpoint_id: str) -> int | None:
|
|
477
|
+
for i, e in enumerate(self.endpoints):
|
|
478
|
+
if e.id == endpoint_id:
|
|
479
|
+
return i
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
__all__ = [
|
|
484
|
+
"DEFAULT_KEYRING_SERVICE",
|
|
485
|
+
"Endpoint",
|
|
486
|
+
"EndpointAuth",
|
|
487
|
+
"EndpointError",
|
|
488
|
+
"EndpointHealth",
|
|
489
|
+
"EndpointStore",
|
|
490
|
+
"SCHEMA_VERSION",
|
|
491
|
+
"VALID_AUTH_KINDS",
|
|
492
|
+
"default_store_path",
|
|
493
|
+
]
|