stravinsky 0.4.18__py3-none-any.whl → 0.4.66__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.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +0 -1
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +3 -5
- mcp_bridge/config/rate_limits.py +108 -13
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +14 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +35 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +3 -4
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +363 -34
- mcp_bridge/server_tools.py +298 -6
- mcp_bridge/tools/__init__.py +19 -8
- mcp_bridge/tools/agent_manager.py +549 -799
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +54 -51
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +8 -8
- mcp_bridge/tools/lsp/manager.py +51 -28
- mcp_bridge/tools/lsp/tools.py +98 -65
- mcp_bridge/tools/model_invoke.py +1047 -152
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +132 -49
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +677 -92
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +33 -37
- mcp_bridge/update_manager_pypi.py +6 -8
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.4.18.dist-info/RECORD +0 -88
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from mcp_bridge.auth.token_store import TokenStore
|
|
12
|
+
from mcp_bridge.tools.model_invoke import invoke_gemini, invoke_openai
|
|
13
|
+
|
|
14
|
+
# Configure logging
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.INFO,
|
|
17
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
18
|
+
)
|
|
19
|
+
logger = logging.getLogger("stravinsky.proxy")
|
|
20
|
+
|
|
21
|
+
app = FastAPI(title="Stravinsky Model Proxy")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.middleware("http")
|
|
25
|
+
async def add_process_time_header(request: Request, call_next):
|
|
26
|
+
request_id = str(uuid.uuid4())
|
|
27
|
+
start_time = time.time()
|
|
28
|
+
logger.info(f"[{request_id}] {request.method} {request.url.path}")
|
|
29
|
+
response = await call_next(request)
|
|
30
|
+
process_time = time.time() - start_time
|
|
31
|
+
response.headers["X-Process-Time"] = str(process_time)
|
|
32
|
+
response.headers["X-Request-ID"] = request_id
|
|
33
|
+
logger.info(f"[{request_id}] Completed in {process_time:.4f}s")
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Shared token store
|
|
38
|
+
_token_store = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_token_store():
|
|
42
|
+
global _token_store
|
|
43
|
+
if _token_store is None:
|
|
44
|
+
_token_store = TokenStore()
|
|
45
|
+
return _token_store
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GeminiRequest(BaseModel):
|
|
49
|
+
prompt: str
|
|
50
|
+
model: str = "gemini-3-flash"
|
|
51
|
+
temperature: float = 0.7
|
|
52
|
+
max_tokens: int = 8192
|
|
53
|
+
thinking_budget: int = 0
|
|
54
|
+
image_path: str | None = None
|
|
55
|
+
agent_context: dict[str, Any] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GeminiAgenticRequest(BaseModel):
|
|
59
|
+
prompt: str
|
|
60
|
+
model: str = "gemini-3-flash"
|
|
61
|
+
max_turns: int = 10
|
|
62
|
+
timeout: int = 120
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OpenAIRequest(BaseModel):
|
|
66
|
+
prompt: str
|
|
67
|
+
model: str = "gpt-5.2-codex"
|
|
68
|
+
temperature: float = 0.7
|
|
69
|
+
max_tokens: int = 4096
|
|
70
|
+
thinking_budget: int = 0
|
|
71
|
+
reasoning_effort: str = "medium"
|
|
72
|
+
agent_context: dict[str, Any] | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.get("/health")
|
|
76
|
+
async def health():
|
|
77
|
+
return {"status": "ok"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.post("/v1/gemini/generate")
|
|
81
|
+
async def gemini_generate(request: GeminiRequest):
|
|
82
|
+
"""Proxy endpoint for Gemini generation."""
|
|
83
|
+
try:
|
|
84
|
+
token_store = get_token_store()
|
|
85
|
+
# We need to ensure agent_context is passed correctly if invoke_gemini supports it
|
|
86
|
+
# Based on previous read, invoke_gemini takes image_path, but agent_context
|
|
87
|
+
# might be extracted from hooks or passed in params.
|
|
88
|
+
# Actually, invoke_gemini extracts it from params.
|
|
89
|
+
|
|
90
|
+
response = await invoke_gemini(
|
|
91
|
+
token_store=token_store,
|
|
92
|
+
prompt=request.prompt,
|
|
93
|
+
model=request.model,
|
|
94
|
+
temperature=request.temperature,
|
|
95
|
+
max_tokens=request.max_tokens,
|
|
96
|
+
thinking_budget=request.thinking_budget,
|
|
97
|
+
image_path=request.image_path,
|
|
98
|
+
)
|
|
99
|
+
return {"response": response}
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Error in gemini_generate proxy: {e}", exc_info=True)
|
|
102
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.post("/v1/gemini/agentic")
|
|
106
|
+
async def gemini_agentic(request: GeminiAgenticRequest):
|
|
107
|
+
"""Proxy endpoint for Agentic Gemini."""
|
|
108
|
+
try:
|
|
109
|
+
from mcp_bridge.tools.model_invoke import invoke_gemini_agentic
|
|
110
|
+
|
|
111
|
+
token_store = get_token_store()
|
|
112
|
+
response = await invoke_gemini_agentic(
|
|
113
|
+
token_store=token_store,
|
|
114
|
+
prompt=request.prompt,
|
|
115
|
+
model=request.model,
|
|
116
|
+
max_turns=request.max_turns,
|
|
117
|
+
timeout=request.timeout,
|
|
118
|
+
)
|
|
119
|
+
return {"response": response}
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Error in gemini_agentic proxy: {e}", exc_info=True)
|
|
122
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.post("/v1/openai/chat")
|
|
126
|
+
async def openai_chat(request: OpenAIRequest):
|
|
127
|
+
"""Proxy endpoint for OpenAI chat."""
|
|
128
|
+
try:
|
|
129
|
+
token_store = get_token_store()
|
|
130
|
+
response = await invoke_openai(
|
|
131
|
+
token_store=token_store,
|
|
132
|
+
prompt=request.prompt,
|
|
133
|
+
model=request.model,
|
|
134
|
+
temperature=request.temperature,
|
|
135
|
+
max_tokens=request.max_tokens,
|
|
136
|
+
thinking_budget=request.thinking_budget,
|
|
137
|
+
reasoning_effort=request.reasoning_effort,
|
|
138
|
+
)
|
|
139
|
+
return {"response": response}
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Error in openai_chat proxy: {e}", exc_info=True)
|
|
142
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main():
|
|
146
|
+
"""Entry point for the proxy server."""
|
|
147
|
+
# CRITICAL: Disable proxy usage within the proxy process to avoid infinite loops
|
|
148
|
+
os.environ["STRAVINSKY_USE_PROXY"] = "false"
|
|
149
|
+
|
|
150
|
+
port = int(os.getenv("STRAVINSKY_PROXY_PORT", 8765))
|
|
151
|
+
host = os.getenv("STRAVINSKY_PROXY_HOST", "127.0.0.1")
|
|
152
|
+
logger.info(f"Starting Stravinsky Model Proxy on {host}:{port}")
|
|
153
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stravinsky Multi-Provider Routing System.
|
|
3
|
+
|
|
4
|
+
This module provides intelligent routing between providers (Claude, OpenAI, Gemini)
|
|
5
|
+
with automatic fallback when providers hit rate limits or capacity constraints.
|
|
6
|
+
|
|
7
|
+
Components:
|
|
8
|
+
- ProviderState: Tracks availability of each provider
|
|
9
|
+
- ProviderStateTracker: Global state management for all providers
|
|
10
|
+
- TaskClassifier: Classifies tasks to route to optimal providers
|
|
11
|
+
- RoutingConfig: Project-local configuration loader
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .config import (
|
|
15
|
+
DEFAULT_ROUTING_CONFIG,
|
|
16
|
+
RoutingConfig,
|
|
17
|
+
load_routing_config,
|
|
18
|
+
)
|
|
19
|
+
from .provider_state import (
|
|
20
|
+
ProviderState,
|
|
21
|
+
ProviderStateTracker,
|
|
22
|
+
get_provider_tracker,
|
|
23
|
+
)
|
|
24
|
+
from .task_classifier import (
|
|
25
|
+
TaskType,
|
|
26
|
+
classify_task,
|
|
27
|
+
get_routing_for_task,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Provider state
|
|
32
|
+
"ProviderState",
|
|
33
|
+
"ProviderStateTracker",
|
|
34
|
+
"get_provider_tracker",
|
|
35
|
+
# Task classification
|
|
36
|
+
"TaskType",
|
|
37
|
+
"classify_task",
|
|
38
|
+
"get_routing_for_task",
|
|
39
|
+
# Configuration
|
|
40
|
+
"load_routing_config",
|
|
41
|
+
"RoutingConfig",
|
|
42
|
+
"DEFAULT_ROUTING_CONFIG",
|
|
43
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Routing Configuration with Project-Local Priority.
|
|
3
|
+
|
|
4
|
+
Loads routing configuration from:
|
|
5
|
+
1. .stravinsky/routing.json (project-local - highest priority)
|
|
6
|
+
2. ~/.stravinsky/routing.json (user-global fallback)
|
|
7
|
+
3. Built-in defaults
|
|
8
|
+
|
|
9
|
+
This allows per-project customization of routing behavior.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Default routing configuration
|
|
23
|
+
DEFAULT_ROUTING_CONFIG: dict[str, Any] = {
|
|
24
|
+
"routing": {
|
|
25
|
+
"enabled": True,
|
|
26
|
+
"task_routing": {
|
|
27
|
+
"code_generation": {
|
|
28
|
+
"provider": "claude",
|
|
29
|
+
"model": "claude-4.5-opus",
|
|
30
|
+
"tier": "premium",
|
|
31
|
+
},
|
|
32
|
+
"code_refactoring": {
|
|
33
|
+
"provider": "claude",
|
|
34
|
+
"model": "claude-4.5-sonnet",
|
|
35
|
+
"tier": "standard",
|
|
36
|
+
},
|
|
37
|
+
"debugging": {
|
|
38
|
+
"provider": "openai",
|
|
39
|
+
"model": "gpt-5.2",
|
|
40
|
+
"tier": "standard",
|
|
41
|
+
},
|
|
42
|
+
"architecture": {
|
|
43
|
+
"provider": "openai",
|
|
44
|
+
"model": "gpt-5.2",
|
|
45
|
+
"tier": "standard",
|
|
46
|
+
},
|
|
47
|
+
"documentation": {
|
|
48
|
+
"provider": "gemini",
|
|
49
|
+
"model": "gemini-3-flash-preview",
|
|
50
|
+
"tier": "standard",
|
|
51
|
+
},
|
|
52
|
+
"code_search": {
|
|
53
|
+
"provider": "gemini",
|
|
54
|
+
"model": "gemini-3-flash-preview",
|
|
55
|
+
"tier": "standard",
|
|
56
|
+
},
|
|
57
|
+
"security_review": {
|
|
58
|
+
"provider": "claude",
|
|
59
|
+
"model": "claude-4.5-opus",
|
|
60
|
+
"tier": "premium",
|
|
61
|
+
},
|
|
62
|
+
"general": {
|
|
63
|
+
"provider": "claude",
|
|
64
|
+
"model": "claude-4.5-sonnet",
|
|
65
|
+
"tier": "standard",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"fallback": {
|
|
69
|
+
"enabled": True,
|
|
70
|
+
"chain": ["claude", "openai", "gemini"],
|
|
71
|
+
"cooldown_seconds": 300,
|
|
72
|
+
},
|
|
73
|
+
"claude_limits": {
|
|
74
|
+
"detection_enabled": True,
|
|
75
|
+
"slow_response_threshold_seconds": 30,
|
|
76
|
+
"auto_fallback": True,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class TaskRoutingRule:
|
|
84
|
+
"""Routing rule for a specific task type."""
|
|
85
|
+
|
|
86
|
+
provider: str
|
|
87
|
+
model: str | None = None
|
|
88
|
+
tier: Literal["premium", "standard"] = "standard"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class FallbackConfig:
|
|
93
|
+
"""Fallback configuration."""
|
|
94
|
+
|
|
95
|
+
enabled: bool = True
|
|
96
|
+
chain: list[str] = field(default_factory=lambda: ["claude", "openai", "gemini"])
|
|
97
|
+
cooldown_seconds: int = 300
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ClaudeLimitsConfig:
|
|
102
|
+
"""Claude limits detection configuration."""
|
|
103
|
+
|
|
104
|
+
detection_enabled: bool = True
|
|
105
|
+
slow_response_threshold_seconds: int = 30
|
|
106
|
+
auto_fallback: bool = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RoutingConfig:
|
|
111
|
+
"""Complete routing configuration."""
|
|
112
|
+
|
|
113
|
+
enabled: bool = True
|
|
114
|
+
task_routing: dict[str, TaskRoutingRule] = field(default_factory=dict)
|
|
115
|
+
fallback: FallbackConfig = field(default_factory=FallbackConfig)
|
|
116
|
+
claude_limits: ClaudeLimitsConfig = field(default_factory=ClaudeLimitsConfig)
|
|
117
|
+
source: str = "default" # Where config was loaded from
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, data: dict[str, Any], source: str = "dict") -> RoutingConfig:
|
|
121
|
+
"""Create RoutingConfig from a dictionary."""
|
|
122
|
+
routing = data.get("routing", data) # Handle both wrapped and unwrapped
|
|
123
|
+
|
|
124
|
+
# Parse task routing
|
|
125
|
+
task_routing = {}
|
|
126
|
+
for task_type, rule in routing.get("task_routing", {}).items():
|
|
127
|
+
if isinstance(rule, dict):
|
|
128
|
+
task_routing[task_type] = TaskRoutingRule(
|
|
129
|
+
provider=rule.get("provider", "claude"),
|
|
130
|
+
model=rule.get("model"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Parse fallback config
|
|
134
|
+
fallback_data = routing.get("fallback", {})
|
|
135
|
+
fallback = FallbackConfig(
|
|
136
|
+
enabled=fallback_data.get("enabled", True),
|
|
137
|
+
chain=fallback_data.get("chain", ["claude", "openai", "gemini"]),
|
|
138
|
+
cooldown_seconds=fallback_data.get("cooldown_seconds", 300),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Parse claude limits config
|
|
142
|
+
claude_data = routing.get("claude_limits", {})
|
|
143
|
+
claude_limits = ClaudeLimitsConfig(
|
|
144
|
+
detection_enabled=claude_data.get("detection_enabled", True),
|
|
145
|
+
slow_response_threshold_seconds=claude_data.get("slow_response_threshold_seconds", 30),
|
|
146
|
+
auto_fallback=claude_data.get("auto_fallback", True),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return cls(
|
|
150
|
+
enabled=routing.get("enabled", True),
|
|
151
|
+
task_routing=task_routing,
|
|
152
|
+
fallback=fallback,
|
|
153
|
+
claude_limits=claude_limits,
|
|
154
|
+
source=source,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def get_routing_for_task(self, task_type: str) -> TaskRoutingRule:
|
|
158
|
+
"""Get routing rule for a task type, with fallback to general."""
|
|
159
|
+
if task_type in self.task_routing:
|
|
160
|
+
return self.task_routing[task_type]
|
|
161
|
+
if "general" in self.task_routing:
|
|
162
|
+
return self.task_routing["general"]
|
|
163
|
+
return TaskRoutingRule(provider="claude", model=None)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_routing_config(project_path: str = ".") -> RoutingConfig:
|
|
167
|
+
"""
|
|
168
|
+
Load routing config with project-local priority.
|
|
169
|
+
|
|
170
|
+
Discovery order:
|
|
171
|
+
1. .stravinsky/routing.json (project-local)
|
|
172
|
+
2. ~/.stravinsky/routing.json (user-global)
|
|
173
|
+
3. Built-in defaults
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
project_path: Path to the project root
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
RoutingConfig instance
|
|
180
|
+
"""
|
|
181
|
+
# Project-local config
|
|
182
|
+
project_config_path = Path(project_path) / ".stravinsky" / "routing.json"
|
|
183
|
+
if project_config_path.exists():
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(project_config_path.read_text())
|
|
186
|
+
config = RoutingConfig.from_dict(data, source=str(project_config_path))
|
|
187
|
+
logger.info(f"[RoutingConfig] Loaded project-local config from {project_config_path}")
|
|
188
|
+
return config
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.warning(f"[RoutingConfig] Failed to load {project_config_path}: {e}")
|
|
191
|
+
|
|
192
|
+
# User-global fallback
|
|
193
|
+
global_config_path = Path.home() / ".stravinsky" / "routing.json"
|
|
194
|
+
if global_config_path.exists():
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(global_config_path.read_text())
|
|
197
|
+
config = RoutingConfig.from_dict(data, source=str(global_config_path))
|
|
198
|
+
logger.info(f"[RoutingConfig] Loaded user-global config from {global_config_path}")
|
|
199
|
+
return config
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.warning(f"[RoutingConfig] Failed to load {global_config_path}: {e}")
|
|
202
|
+
|
|
203
|
+
# Built-in defaults
|
|
204
|
+
logger.info("[RoutingConfig] Using built-in defaults")
|
|
205
|
+
return RoutingConfig.from_dict(DEFAULT_ROUTING_CONFIG, source="default")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def init_routing_config(project_path: str = ".") -> Path:
|
|
209
|
+
"""
|
|
210
|
+
Initialize a project-local routing config file.
|
|
211
|
+
|
|
212
|
+
Creates .stravinsky/routing.json with default configuration.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
project_path: Path to the project root
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Path to the created config file
|
|
219
|
+
"""
|
|
220
|
+
config_dir = Path(project_path) / ".stravinsky"
|
|
221
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
|
|
223
|
+
config_path = config_dir / "routing.json"
|
|
224
|
+
|
|
225
|
+
if config_path.exists():
|
|
226
|
+
logger.warning(f"[RoutingConfig] Config already exists at {config_path}")
|
|
227
|
+
return config_path
|
|
228
|
+
|
|
229
|
+
config_path.write_text(json.dumps(DEFAULT_ROUTING_CONFIG, indent=2))
|
|
230
|
+
logger.info(f"[RoutingConfig] Created config at {config_path}")
|
|
231
|
+
|
|
232
|
+
return config_path
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_config_source(project_path: str = ".") -> str:
|
|
236
|
+
"""
|
|
237
|
+
Get the source of the active routing config.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Path to the config file being used, or "default" for built-in
|
|
241
|
+
"""
|
|
242
|
+
project_config = Path(project_path) / ".stravinsky" / "routing.json"
|
|
243
|
+
if project_config.exists():
|
|
244
|
+
return str(project_config)
|
|
245
|
+
|
|
246
|
+
global_config = Path.home() / ".stravinsky" / "routing.json"
|
|
247
|
+
if global_config.exists():
|
|
248
|
+
return str(global_config)
|
|
249
|
+
|
|
250
|
+
return "default"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Model tier definitions and cross-provider fallback planning.
|
|
2
|
+
|
|
3
|
+
This module centralizes a simple, two-tier model architecture and provides
|
|
4
|
+
a deterministic fallback chain when an OAuth call fails or is unavailable.
|
|
5
|
+
|
|
6
|
+
The fallback chain is ordered to prefer:
|
|
7
|
+
1) Same-tier OAuth models on *other* providers
|
|
8
|
+
2) Lower-tier OAuth models (if available)
|
|
9
|
+
3) Same-tier models via API key auth
|
|
10
|
+
|
|
11
|
+
The boolean in the returned tuples indicates whether OAuth should be used.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelTier(str, Enum):
|
|
22
|
+
PREMIUM = "premium"
|
|
23
|
+
STANDARD = "standard"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TierModel:
|
|
28
|
+
model: str
|
|
29
|
+
thinking: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
KNOWN_PROVIDERS: Final[tuple[str, ...]] = ("claude", "openai", "gemini")
|
|
33
|
+
|
|
34
|
+
# Provider preference order mirrors existing routing fallback chains.
|
|
35
|
+
PROVIDER_FALLBACK_ORDER: Final[dict[str, list[str]]] = {
|
|
36
|
+
"claude": ["openai", "gemini"],
|
|
37
|
+
"openai": ["gemini", "claude"],
|
|
38
|
+
"gemini": ["openai", "claude"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Ordered best -> worst.
|
|
42
|
+
TIER_ORDER: Final[tuple[ModelTier, ...]] = (ModelTier.PREMIUM, ModelTier.STANDARD)
|
|
43
|
+
|
|
44
|
+
MODEL_TIERS: Final[dict[ModelTier, dict[str, TierModel]]] = {
|
|
45
|
+
ModelTier.PREMIUM: {
|
|
46
|
+
"claude": TierModel(model="claude-4.5-opus", thinking=True),
|
|
47
|
+
"openai": TierModel(model="gpt-5.2-codex", thinking=False),
|
|
48
|
+
"gemini": TierModel(model="gemini-3-pro", thinking=False),
|
|
49
|
+
},
|
|
50
|
+
ModelTier.STANDARD: {
|
|
51
|
+
"claude": TierModel(model="claude-4.5-sonnet", thinking=False),
|
|
52
|
+
"openai": TierModel(model="gpt-5.2", thinking=False),
|
|
53
|
+
"gemini": TierModel(model="gemini-3-flash-preview", thinking=False),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_known_provider(provider: str) -> None:
|
|
59
|
+
if provider not in KNOWN_PROVIDERS:
|
|
60
|
+
raise ValueError(f"Unknown provider: {provider!r}. Expected one of {KNOWN_PROVIDERS!r}.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tier_for(provider: str, model: str) -> ModelTier:
|
|
64
|
+
_require_known_provider(provider)
|
|
65
|
+
|
|
66
|
+
for tier, tier_models in MODEL_TIERS.items():
|
|
67
|
+
spec = tier_models.get(provider)
|
|
68
|
+
if spec and spec.model == model:
|
|
69
|
+
return tier
|
|
70
|
+
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Unknown model for provider {provider!r}: {model!r}. "
|
|
73
|
+
"Expected a model present in MODEL_TIERS."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _providers_other_first(provider: str) -> list[str]:
|
|
78
|
+
_require_known_provider(provider)
|
|
79
|
+
preferred = PROVIDER_FALLBACK_ORDER.get(provider)
|
|
80
|
+
if preferred is not None:
|
|
81
|
+
return [p for p in preferred if p != provider]
|
|
82
|
+
return [p for p in KNOWN_PROVIDERS if p != provider]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _lower_tiers(tier: ModelTier) -> list[ModelTier]:
|
|
86
|
+
try:
|
|
87
|
+
idx = TIER_ORDER.index(tier)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return []
|
|
90
|
+
return list(TIER_ORDER[idx + 1 :])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_oauth_fallback_chain(provider: str, model: str) -> list[tuple[str, str, bool]]:
|
|
94
|
+
"""Return ordered (provider, model, use_oauth) fallbacks.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
provider: Current provider (e.g. "openai")
|
|
98
|
+
model: Current model identifier within that provider
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A list of candidate (provider, model, use_oauth) tuples.
|
|
102
|
+
|
|
103
|
+
Ordering rules:
|
|
104
|
+
- Same-tier models on OTHER providers first (OAuth)
|
|
105
|
+
- Then lower-tier models (OAuth)
|
|
106
|
+
- Then same-tier models via API key (non-OAuth)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
tier = _tier_for(provider, model)
|
|
110
|
+
other_providers = _providers_other_first(provider)
|
|
111
|
+
|
|
112
|
+
chain: list[tuple[str, str, bool]] = []
|
|
113
|
+
seen: set[tuple[str, str, bool]] = set()
|
|
114
|
+
|
|
115
|
+
def add(p: str, m: str, use_oauth: bool) -> None:
|
|
116
|
+
item = (p, m, use_oauth)
|
|
117
|
+
if item in seen:
|
|
118
|
+
return
|
|
119
|
+
seen.add(item)
|
|
120
|
+
chain.append(item)
|
|
121
|
+
|
|
122
|
+
# 1) Same tier, other providers, OAuth first.
|
|
123
|
+
for p in other_providers:
|
|
124
|
+
add(p, MODEL_TIERS[tier][p].model, True)
|
|
125
|
+
|
|
126
|
+
# 2) Lower tiers, OAuth.
|
|
127
|
+
for lower in _lower_tiers(tier):
|
|
128
|
+
for p in [*other_providers, provider]:
|
|
129
|
+
add(p, MODEL_TIERS[lower][p].model, True)
|
|
130
|
+
|
|
131
|
+
# 3) Same tier, API key (non-OAuth).
|
|
132
|
+
for p in [provider, *other_providers]:
|
|
133
|
+
add(p, MODEL_TIERS[tier][p].model, False)
|
|
134
|
+
|
|
135
|
+
return chain
|