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.

Files changed (184) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +0 -1
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/__init__.py +2 -2
  11. mcp_bridge/config/hook_config.py +3 -5
  12. mcp_bridge/config/rate_limits.py +108 -13
  13. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  14. mcp_bridge/hooks/__init__.py +14 -4
  15. mcp_bridge/hooks/agent_reminder.py +4 -4
  16. mcp_bridge/hooks/auto_slash_command.py +5 -5
  17. mcp_bridge/hooks/budget_optimizer.py +2 -2
  18. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  19. mcp_bridge/hooks/comment_checker.py +3 -4
  20. mcp_bridge/hooks/compaction.py +2 -2
  21. mcp_bridge/hooks/context.py +2 -1
  22. mcp_bridge/hooks/context_monitor.py +2 -2
  23. mcp_bridge/hooks/delegation_policy.py +85 -0
  24. mcp_bridge/hooks/directory_context.py +3 -3
  25. mcp_bridge/hooks/edit_recovery.py +3 -2
  26. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  27. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  28. mcp_bridge/hooks/events.py +160 -0
  29. mcp_bridge/hooks/git_noninteractive.py +4 -4
  30. mcp_bridge/hooks/keyword_detector.py +8 -10
  31. mcp_bridge/hooks/manager.py +35 -22
  32. mcp_bridge/hooks/notification_hook.py +13 -6
  33. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  34. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  35. mcp_bridge/hooks/parallel_execution.py +22 -10
  36. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  37. mcp_bridge/hooks/pre_compact.py +8 -9
  38. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  39. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  40. mcp_bridge/hooks/routing_notifications.py +80 -0
  41. mcp_bridge/hooks/rules_injector.py +11 -19
  42. mcp_bridge/hooks/session_idle.py +4 -4
  43. mcp_bridge/hooks/session_notifier.py +4 -4
  44. mcp_bridge/hooks/session_recovery.py +4 -5
  45. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  46. mcp_bridge/hooks/subagent_stop.py +1 -3
  47. mcp_bridge/hooks/task_validator.py +2 -2
  48. mcp_bridge/hooks/tmux_manager.py +7 -8
  49. mcp_bridge/hooks/todo_delegation.py +4 -1
  50. mcp_bridge/hooks/todo_enforcer.py +180 -10
  51. mcp_bridge/hooks/truncation_policy.py +37 -0
  52. mcp_bridge/hooks/truncator.py +1 -2
  53. mcp_bridge/metrics/cost_tracker.py +115 -0
  54. mcp_bridge/native_search.py +93 -0
  55. mcp_bridge/native_watcher.py +118 -0
  56. mcp_bridge/notifications.py +3 -4
  57. mcp_bridge/orchestrator/enums.py +11 -0
  58. mcp_bridge/orchestrator/router.py +165 -0
  59. mcp_bridge/orchestrator/state.py +32 -0
  60. mcp_bridge/orchestrator/visualization.py +14 -0
  61. mcp_bridge/orchestrator/wisdom.py +34 -0
  62. mcp_bridge/prompts/__init__.py +1 -8
  63. mcp_bridge/prompts/dewey.py +1 -1
  64. mcp_bridge/prompts/planner.py +2 -4
  65. mcp_bridge/prompts/stravinsky.py +53 -31
  66. mcp_bridge/proxy/__init__.py +0 -0
  67. mcp_bridge/proxy/client.py +70 -0
  68. mcp_bridge/proxy/model_server.py +157 -0
  69. mcp_bridge/routing/__init__.py +43 -0
  70. mcp_bridge/routing/config.py +250 -0
  71. mcp_bridge/routing/model_tiers.py +135 -0
  72. mcp_bridge/routing/provider_state.py +261 -0
  73. mcp_bridge/routing/task_classifier.py +190 -0
  74. mcp_bridge/server.py +363 -34
  75. mcp_bridge/server_tools.py +298 -6
  76. mcp_bridge/tools/__init__.py +19 -8
  77. mcp_bridge/tools/agent_manager.py +549 -799
  78. mcp_bridge/tools/background_tasks.py +13 -17
  79. mcp_bridge/tools/code_search.py +54 -51
  80. mcp_bridge/tools/continuous_loop.py +0 -1
  81. mcp_bridge/tools/dashboard.py +19 -0
  82. mcp_bridge/tools/find_code.py +296 -0
  83. mcp_bridge/tools/init.py +1 -0
  84. mcp_bridge/tools/list_directory.py +42 -0
  85. mcp_bridge/tools/lsp/__init__.py +8 -8
  86. mcp_bridge/tools/lsp/manager.py +51 -28
  87. mcp_bridge/tools/lsp/tools.py +98 -65
  88. mcp_bridge/tools/model_invoke.py +1047 -152
  89. mcp_bridge/tools/mux_client.py +75 -0
  90. mcp_bridge/tools/project_context.py +1 -2
  91. mcp_bridge/tools/query_classifier.py +132 -49
  92. mcp_bridge/tools/read_file.py +84 -0
  93. mcp_bridge/tools/replace.py +45 -0
  94. mcp_bridge/tools/run_shell_command.py +38 -0
  95. mcp_bridge/tools/search_enhancements.py +347 -0
  96. mcp_bridge/tools/semantic_search.py +677 -92
  97. mcp_bridge/tools/session_manager.py +0 -2
  98. mcp_bridge/tools/skill_loader.py +0 -1
  99. mcp_bridge/tools/task_runner.py +5 -7
  100. mcp_bridge/tools/templates.py +3 -3
  101. mcp_bridge/tools/tool_search.py +331 -0
  102. mcp_bridge/tools/write_file.py +29 -0
  103. mcp_bridge/update_manager.py +33 -37
  104. mcp_bridge/update_manager_pypi.py +6 -8
  105. mcp_bridge/utils/cache.py +82 -0
  106. mcp_bridge/utils/process.py +71 -0
  107. mcp_bridge/utils/session_state.py +51 -0
  108. mcp_bridge/utils/truncation.py +76 -0
  109. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
  110. stravinsky-0.4.66.dist-info/RECORD +198 -0
  111. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  112. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  113. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  114. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  115. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  116. stravinsky_claude_assets/agents/debugger.md +254 -0
  117. stravinsky_claude_assets/agents/delphi.md +495 -0
  118. stravinsky_claude_assets/agents/dewey.md +248 -0
  119. stravinsky_claude_assets/agents/explore.md +1198 -0
  120. stravinsky_claude_assets/agents/frontend.md +472 -0
  121. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  122. stravinsky_claude_assets/agents/momus.md +464 -0
  123. stravinsky_claude_assets/agents/research-lead.md +141 -0
  124. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  125. stravinsky_claude_assets/commands/delphi.md +9 -0
  126. stravinsky_claude_assets/commands/dewey.md +54 -0
  127. stravinsky_claude_assets/commands/git-master.md +112 -0
  128. stravinsky_claude_assets/commands/index.md +49 -0
  129. stravinsky_claude_assets/commands/publish.md +86 -0
  130. stravinsky_claude_assets/commands/review.md +73 -0
  131. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  132. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  133. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  134. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  135. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  136. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  137. stravinsky_claude_assets/commands/str/clean.md +97 -0
  138. stravinsky_claude_assets/commands/str/continue.md +38 -0
  139. stravinsky_claude_assets/commands/str/index.md +199 -0
  140. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  141. stravinsky_claude_assets/commands/str/search.md +205 -0
  142. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  143. stravinsky_claude_assets/commands/str/stats.md +71 -0
  144. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  145. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  146. stravinsky_claude_assets/commands/str/watch.md +45 -0
  147. stravinsky_claude_assets/commands/strav.md +53 -0
  148. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  149. stravinsky_claude_assets/commands/verify.md +60 -0
  150. stravinsky_claude_assets/commands/version.md +5 -0
  151. stravinsky_claude_assets/hooks/README.md +248 -0
  152. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  153. stravinsky_claude_assets/hooks/context.py +38 -0
  154. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  155. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  156. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  157. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  158. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  159. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  160. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  161. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  162. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  163. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  164. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  165. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  166. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  167. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  168. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  169. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  170. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  171. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  172. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  173. stravinsky_claude_assets/hooks/truncator.py +23 -0
  174. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  175. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  176. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  177. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  178. stravinsky_claude_assets/settings.json +152 -0
  179. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  180. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  181. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  182. stravinsky_claude_assets/task_dependencies.json +34 -0
  183. stravinsky-0.4.18.dist-info/RECORD +0 -88
  184. {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