AUXteam commited on
Commit
1397957
·
verified ·
1 Parent(s): a8a2486

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .hfignore +9 -0
  2. Dockerfile +21 -0
  3. README.md +95 -5
  4. __pycache__/app.cpython-312.pyc +0 -0
  5. app.py +100 -0
  6. requirements.txt +38 -0
  7. src/opencode_api/__init__.py +3 -0
  8. src/opencode_api/__pycache__/__init__.cpython-312.pyc +0 -0
  9. src/opencode_api/agent/__init__.py +35 -0
  10. src/opencode_api/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  11. src/opencode_api/agent/__pycache__/agent.cpython-312.pyc +0 -0
  12. src/opencode_api/agent/agent.py +215 -0
  13. src/opencode_api/agent/prompts/anthropic.txt +85 -0
  14. src/opencode_api/agent/prompts/beast.txt +103 -0
  15. src/opencode_api/agent/prompts/gemini.txt +67 -0
  16. src/opencode_api/core/__init__.py +8 -0
  17. src/opencode_api/core/__pycache__/__init__.cpython-312.pyc +0 -0
  18. src/opencode_api/core/__pycache__/auth.cpython-312.pyc +0 -0
  19. src/opencode_api/core/__pycache__/bus.cpython-312.pyc +0 -0
  20. src/opencode_api/core/__pycache__/config.cpython-312.pyc +0 -0
  21. src/opencode_api/core/__pycache__/identifier.cpython-312.pyc +0 -0
  22. src/opencode_api/core/__pycache__/quota.cpython-312.pyc +0 -0
  23. src/opencode_api/core/__pycache__/storage.cpython-312.pyc +0 -0
  24. src/opencode_api/core/__pycache__/supabase.cpython-312.pyc +0 -0
  25. src/opencode_api/core/auth.py +79 -0
  26. src/opencode_api/core/bus.py +153 -0
  27. src/opencode_api/core/config.py +104 -0
  28. src/opencode_api/core/identifier.py +69 -0
  29. src/opencode_api/core/quota.py +91 -0
  30. src/opencode_api/core/storage.py +145 -0
  31. src/opencode_api/core/supabase.py +25 -0
  32. src/opencode_api/provider/__init__.py +39 -0
  33. src/opencode_api/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  34. src/opencode_api/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  35. src/opencode_api/provider/__pycache__/blablador.cpython-312.pyc +0 -0
  36. src/opencode_api/provider/__pycache__/gemini.cpython-312.pyc +0 -0
  37. src/opencode_api/provider/__pycache__/litellm.cpython-312.pyc +0 -0
  38. src/opencode_api/provider/__pycache__/openai.cpython-312.pyc +0 -0
  39. src/opencode_api/provider/__pycache__/provider.cpython-312.pyc +0 -0
  40. src/opencode_api/provider/anthropic.py +204 -0
  41. src/opencode_api/provider/blablador.py +57 -0
  42. src/opencode_api/provider/gemini.py +215 -0
  43. src/opencode_api/provider/litellm.py +363 -0
  44. src/opencode_api/provider/openai.py +182 -0
  45. src/opencode_api/provider/provider.py +133 -0
  46. src/opencode_api/routes/__init__.py +7 -0
  47. src/opencode_api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  48. src/opencode_api/routes/__pycache__/agent.cpython-312.pyc +0 -0
  49. src/opencode_api/routes/__pycache__/event.cpython-312.pyc +0 -0
  50. src/opencode_api/routes/__pycache__/provider.cpython-312.pyc +0 -0
.hfignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .github/
3
+ docs/
4
+ sql/
5
+ test_gemini_tools.py
6
+ .env.example
7
+ .gitignore
8
+ ROADMAP.md
9
+ pyproject.toml
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ ENV PYTHONPATH=/app
15
+ ENV OPENCODE_STORAGE_PATH=/app
16
+
17
+ RUN chmod -R 777 /app
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,100 @@
1
  ---
2
- title: Opencode Api
3
- emoji: 📊
4
- colorFrom: yellow
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: opencode-api
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
  ---
11
 
12
+ # OpenCode API
13
+
14
+ LLM Agent API Server - ported from TypeScript [opencode](https://github.com/anomalyco/opencode) to Python.
15
+
16
+ ## Features
17
+
18
+ - **Multi-provider LLM support**: Anthropic (Claude), OpenAI (GPT-4)
19
+ - **Tool system**: Web search, web fetch, todo management
20
+ - **Session management**: Persistent conversations with history
21
+ - **SSE streaming**: Real-time streaming responses
22
+ - **REST API**: FastAPI with automatic OpenAPI docs
23
+
24
+ ## API Endpoints
25
+
26
+ ### Sessions
27
+
28
+ - `GET /session` - List all sessions
29
+ - `POST /session` - Create a new session
30
+ - `GET /session/{id}` - Get session details
31
+ - `DELETE /session/{id}` - Delete a session
32
+ - `POST /session/{id}/message` - Send a message (SSE streaming response)
33
+ - `POST /session/{id}/abort` - Cancel ongoing generation
34
+
35
+ ### Providers
36
+
37
+ - `GET /provider` - List available LLM providers
38
+ - `GET /provider/{id}` - Get provider details
39
+ - `GET /provider/{id}/model` - List provider models
40
+
41
+ ### Events
42
+
43
+ - `GET /event` - Subscribe to real-time events (SSE)
44
+
45
+ ## Environment Variables
46
+
47
+ Set these as Hugging Face Space secrets:
48
+
49
+ | Variable | Description |
50
+ | -------------------------- | ----------------------------------- |
51
+ | `ANTHROPIC_API_KEY` | Anthropic API key for Claude models |
52
+ | `OPENAI_API_KEY` | OpenAI API key for GPT models |
53
+ | `BLABLADOR_API_KEY` | Blablador API key |
54
+ | `TOKEN` | Authentication token for API access |
55
+ | `OPENCODE_SERVER_PASSWORD` | Optional: Basic auth password |
56
+
57
+ ## Local Development
58
+
59
+ ```bash
60
+ # Install dependencies
61
+ pip install -r requirements.txt
62
+
63
+ # Run server
64
+ python app.py
65
+
66
+ # Or with uvicorn
67
+ uvicorn app:app --host 0.0.0.0 --port 7860 --reload
68
+ ```
69
+
70
+ ## API Documentation
71
+
72
+ Once running, visit:
73
+
74
+ - Swagger UI: `http://localhost:7860/docs`
75
+ - ReDoc: `http://localhost:7860/redoc`
76
+
77
+ ## Example Usage
78
+
79
+ ```python
80
+ import httpx
81
+
82
+ # Create a session
83
+ response = httpx.post("http://localhost:7860/session")
84
+ session = response.json()
85
+ session_id = session["id"]
86
+
87
+ # Send a message (with SSE streaming)
88
+ with httpx.stream(
89
+ "POST",
90
+ f"http://localhost:7860/session/{session_id}/message",
91
+ json={"content": "Hello, what can you help me with?"}
92
+ ) as response:
93
+ for line in response.iter_lines():
94
+ if line.startswith("data: "):
95
+ print(line[6:])
96
+ ```
97
+
98
+ ## License
99
+
100
+ MIT
__pycache__/app.cpython-312.pyc ADDED
Binary file (3.72 kB). View file
 
app.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+ from contextlib import asynccontextmanager
5
+ import os
6
+
7
+ from src.opencode_api.routes import session_router, provider_router, event_router, question_router, agent_router
8
+ from src.opencode_api.provider import (
9
+ register_provider,
10
+ AnthropicProvider,
11
+ OpenAIProvider,
12
+ LiteLLMProvider,
13
+ GeminiProvider,
14
+ BlabladorProvider
15
+ )
16
+ from src.opencode_api.tool import register_tool, WebSearchTool, WebFetchTool, TodoTool, QuestionTool, SkillTool
17
+ from src.opencode_api.core.config import settings
18
+
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(app: FastAPI):
22
+ register_provider(BlabladorProvider())
23
+ register_provider(LiteLLMProvider())
24
+ register_provider(AnthropicProvider())
25
+ register_provider(OpenAIProvider())
26
+ register_provider(GeminiProvider(api_key=settings.google_api_key))
27
+
28
+ # Register tools
29
+ register_tool(WebSearchTool())
30
+ register_tool(WebFetchTool())
31
+ register_tool(TodoTool())
32
+ register_tool(QuestionTool())
33
+ register_tool(SkillTool())
34
+
35
+ yield
36
+
37
+
38
+ app = FastAPI(
39
+ title="OpenCode API",
40
+ description="LLM Agent API Server - ported from TypeScript opencode",
41
+ version="0.1.0",
42
+ lifespan=lifespan,
43
+ )
44
+
45
+ # CORS settings for aicampus frontend
46
+ ALLOWED_ORIGINS = [
47
+ "https://aicampus.kr",
48
+ "https://www.aicampus.kr",
49
+ "https://aicampus.vercel.app",
50
+ "http://localhost:3000",
51
+ "http://127.0.0.1:3000",
52
+ ]
53
+
54
+ app.add_middleware(
55
+ CORSMiddleware,
56
+ allow_origins=ALLOWED_ORIGINS,
57
+ allow_credentials=True,
58
+ allow_methods=["*"],
59
+ allow_headers=["*"],
60
+ )
61
+
62
+
63
+ @app.exception_handler(Exception)
64
+ async def global_exception_handler(request: Request, exc: Exception):
65
+ return JSONResponse(
66
+ status_code=500,
67
+ content={"error": str(exc), "type": type(exc).__name__}
68
+ )
69
+
70
+
71
+ app.include_router(session_router)
72
+ app.include_router(provider_router)
73
+ app.include_router(event_router)
74
+ app.include_router(question_router)
75
+ app.include_router(agent_router)
76
+
77
+
78
+ @app.get("/")
79
+ async def root():
80
+ return {
81
+ "name": "OpenCode API",
82
+ "version": "0.1.0",
83
+ "status": "running",
84
+ "docs": "/docs",
85
+ }
86
+
87
+
88
+ @app.get("/health")
89
+ async def health():
90
+ return {"status": "healthy"}
91
+
92
+
93
+ if __name__ == "__main__":
94
+ import uvicorn
95
+ uvicorn.run(
96
+ "app:app",
97
+ host=settings.host,
98
+ port=settings.port,
99
+ reload=settings.debug,
100
+ )
requirements.txt ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI and ASGI server
2
+ fastapi>=0.109.0
3
+ uvicorn[standard]>=0.27.0
4
+
5
+ # LLM SDKs
6
+ anthropic>=0.40.0
7
+ openai>=1.50.0
8
+ litellm>=1.50.0
9
+ google-genai>=1.51.0
10
+
11
+ # Validation and serialization
12
+ pydantic>=2.6.0
13
+ pydantic-settings>=2.1.0
14
+
15
+ # HTTP client for tools
16
+ httpx>=0.27.0
17
+ aiohttp>=3.9.0
18
+
19
+ # Utilities
20
+ python-ulid>=2.2.0
21
+ python-dotenv>=1.0.0
22
+
23
+ # SSE support
24
+ sse-starlette>=2.0.0
25
+
26
+ # Web search (DuckDuckGo)
27
+ ddgs>=9.0.0
28
+
29
+ # HTML to markdown conversion
30
+ html2text>=2024.2.26
31
+ beautifulsoup4>=4.12.0
32
+
33
+ # Async utilities
34
+ anyio>=4.2.0
35
+
36
+ # Supabase integration
37
+ supabase>=2.0.0
38
+ python-jose[cryptography]>=3.3.0
src/opencode_api/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """OpenCode API - LLM Agent API Server for Hugging Face Spaces"""
2
+
3
+ __version__ = "0.1.0"
src/opencode_api/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (233 Bytes). View file
 
src/opencode_api/agent/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent module - agent configurations and system prompts.
3
+ """
4
+
5
+ from .agent import (
6
+ AgentInfo,
7
+ AgentModel,
8
+ AgentPermission,
9
+ get,
10
+ list_agents,
11
+ default_agent,
12
+ register,
13
+ unregister,
14
+ is_tool_allowed,
15
+ get_system_prompt,
16
+ get_prompt_for_provider,
17
+ DEFAULT_AGENTS,
18
+ PROMPTS,
19
+ )
20
+
21
+ __all__ = [
22
+ "AgentInfo",
23
+ "AgentModel",
24
+ "AgentPermission",
25
+ "get",
26
+ "list_agents",
27
+ "default_agent",
28
+ "register",
29
+ "unregister",
30
+ "is_tool_allowed",
31
+ "get_system_prompt",
32
+ "get_prompt_for_provider",
33
+ "DEFAULT_AGENTS",
34
+ "PROMPTS",
35
+ ]
src/opencode_api/agent/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (574 Bytes). View file
 
src/opencode_api/agent/__pycache__/agent.cpython-312.pyc ADDED
Binary file (7.57 kB). View file
 
src/opencode_api/agent/agent.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent module - defines agent configurations and system prompts.
3
+ """
4
+
5
+ from typing import Optional, List, Dict, Any, Literal
6
+ from pydantic import BaseModel, Field
7
+ from pathlib import Path
8
+ import os
9
+
10
+ # Load prompts
11
+ PROMPTS_DIR = Path(__file__).parent / "prompts"
12
+
13
+
14
+ def load_prompt(name: str) -> str:
15
+ """Load a prompt file from the prompts directory."""
16
+ prompt_path = PROMPTS_DIR / f"{name}.txt"
17
+ if prompt_path.exists():
18
+ return prompt_path.read_text()
19
+ return ""
20
+
21
+
22
+ # Cache loaded prompts - provider-specific prompts
23
+ PROMPTS = {
24
+ "anthropic": load_prompt("anthropic"),
25
+ "gemini": load_prompt("gemini"),
26
+ "openai": load_prompt("beast"), # OpenAI uses default beast prompt
27
+ "default": load_prompt("beast"),
28
+ }
29
+
30
+ # Keep for backward compatibility
31
+ BEAST_PROMPT = PROMPTS["default"]
32
+
33
+
34
+ def get_prompt_for_provider(provider_id: str) -> str:
35
+ """Get the appropriate system prompt for a provider.
36
+
37
+ Args:
38
+ provider_id: The provider identifier (e.g., 'anthropic', 'gemini', 'openai')
39
+
40
+ Returns:
41
+ The system prompt optimized for the given provider.
42
+ """
43
+ return PROMPTS.get(provider_id, PROMPTS["default"])
44
+
45
+
46
+ class AgentModel(BaseModel):
47
+ """Model configuration for an agent."""
48
+ provider_id: str
49
+ model_id: str
50
+
51
+
52
+ class AgentPermission(BaseModel):
53
+ """Permission configuration for tool execution."""
54
+ tool_name: str
55
+ action: Literal["allow", "deny", "ask"] = "allow"
56
+ patterns: List[str] = Field(default_factory=list)
57
+
58
+
59
+ class AgentInfo(BaseModel):
60
+ """Agent configuration schema."""
61
+ id: str
62
+ name: str
63
+ description: Optional[str] = None
64
+ mode: Literal["primary", "subagent", "all"] = "primary"
65
+ hidden: bool = False
66
+ native: bool = True
67
+
68
+ # Model settings
69
+ model: Optional[AgentModel] = None
70
+ temperature: Optional[float] = None
71
+ top_p: Optional[float] = None
72
+ max_tokens: Optional[int] = None
73
+
74
+ # Prompt
75
+ prompt: Optional[str] = None
76
+
77
+ # Behavior
78
+ tools: List[str] = Field(default_factory=list, description="Allowed tools, empty = all")
79
+ permissions: List[AgentPermission] = Field(default_factory=list)
80
+
81
+ # Agentic loop settings
82
+ auto_continue: bool = True
83
+ max_steps: int = 50
84
+ pause_on_question: bool = True
85
+
86
+ # Extra options
87
+ options: Dict[str, Any] = Field(default_factory=dict)
88
+
89
+
90
+ # Default agents
91
+ DEFAULT_AGENTS: Dict[str, AgentInfo] = {
92
+ "build": AgentInfo(
93
+ id="build",
94
+ name="build",
95
+ description="Default agent with full capabilities. Continues working until task is complete.",
96
+ mode="primary",
97
+ prompt=BEAST_PROMPT,
98
+ auto_continue=True,
99
+ max_steps=50,
100
+ permissions=[
101
+ AgentPermission(tool_name="*", action="allow"),
102
+ AgentPermission(tool_name="question", action="allow"),
103
+ ],
104
+ ),
105
+ "plan": AgentInfo(
106
+ id="plan",
107
+ name="plan",
108
+ description="Read-only agent for analysis and planning. Does not modify files.",
109
+ mode="primary",
110
+ auto_continue=False,
111
+ permissions=[
112
+ AgentPermission(tool_name="*", action="deny"),
113
+ AgentPermission(tool_name="websearch", action="allow"),
114
+ AgentPermission(tool_name="webfetch", action="allow"),
115
+ AgentPermission(tool_name="todo", action="allow"),
116
+ AgentPermission(tool_name="question", action="allow"),
117
+ AgentPermission(tool_name="skill", action="allow"),
118
+ ],
119
+ ),
120
+ "general": AgentInfo(
121
+ id="general",
122
+ name="general",
123
+ description="General-purpose agent for researching complex questions and executing multi-step tasks.",
124
+ mode="subagent",
125
+ auto_continue=True,
126
+ max_steps=30,
127
+ permissions=[
128
+ AgentPermission(tool_name="*", action="allow"),
129
+ AgentPermission(tool_name="todo", action="deny"),
130
+ ],
131
+ ),
132
+ "explore": AgentInfo(
133
+ id="explore",
134
+ name="explore",
135
+ description="Fast agent specialized for exploring codebases and searching for information.",
136
+ mode="subagent",
137
+ auto_continue=False,
138
+ permissions=[
139
+ AgentPermission(tool_name="*", action="deny"),
140
+ AgentPermission(tool_name="websearch", action="allow"),
141
+ AgentPermission(tool_name="webfetch", action="allow"),
142
+ ],
143
+ ),
144
+ }
145
+
146
+ # Custom agents loaded from config
147
+ _custom_agents: Dict[str, AgentInfo] = {}
148
+
149
+
150
+ def get(agent_id: str) -> Optional[AgentInfo]:
151
+ """Get an agent by ID."""
152
+ if agent_id in _custom_agents:
153
+ return _custom_agents[agent_id]
154
+ return DEFAULT_AGENTS.get(agent_id)
155
+
156
+
157
+ def list_agents(mode: Optional[str] = None, include_hidden: bool = False) -> List[AgentInfo]:
158
+ """List all agents, optionally filtered by mode."""
159
+ all_agents = {**DEFAULT_AGENTS, **_custom_agents}
160
+ agents = []
161
+
162
+ for agent in all_agents.values():
163
+ if agent.hidden and not include_hidden:
164
+ continue
165
+ if mode and agent.mode != mode:
166
+ continue
167
+ agents.append(agent)
168
+
169
+ # Sort by name, with 'build' first
170
+ agents.sort(key=lambda a: (a.name != "build", a.name))
171
+ return agents
172
+
173
+
174
+ def default_agent() -> AgentInfo:
175
+ """Get the default agent (build)."""
176
+ return DEFAULT_AGENTS["build"]
177
+
178
+
179
+ def register(agent: AgentInfo) -> None:
180
+ """Register a custom agent."""
181
+ _custom_agents[agent.id] = agent
182
+
183
+
184
+ def unregister(agent_id: str) -> bool:
185
+ """Unregister a custom agent."""
186
+ if agent_id in _custom_agents:
187
+ del _custom_agents[agent_id]
188
+ return True
189
+ return False
190
+
191
+
192
+ def is_tool_allowed(agent: AgentInfo, tool_name: str) -> Literal["allow", "deny", "ask"]:
193
+ """Check if a tool is allowed for an agent."""
194
+ result: Literal["allow", "deny", "ask"] = "allow"
195
+
196
+ for perm in agent.permissions:
197
+ if perm.tool_name == "*" or perm.tool_name == tool_name:
198
+ result = perm.action
199
+
200
+ return result
201
+
202
+
203
+ def get_system_prompt(agent: AgentInfo) -> str:
204
+ """Get the system prompt for an agent."""
205
+ parts = []
206
+
207
+ # Add beast mode prompt for agents with auto_continue
208
+ if agent.auto_continue and agent.prompt:
209
+ parts.append(agent.prompt)
210
+
211
+ # Add agent description
212
+ if agent.description:
213
+ parts.append(f"You are the '{agent.name}' agent: {agent.description}")
214
+
215
+ return "\n\n".join(parts)
src/opencode_api/agent/prompts/anthropic.txt ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a highly capable AI assistant with access to powerful tools for research, task management, and user interaction.
2
+
3
+ # Tone and Communication Style
4
+ - Be professional, objective, and concise
5
+ - Provide direct, accurate responses without unnecessary elaboration
6
+ - Maintain a helpful but measured tone
7
+ - Avoid casual language, emojis, or excessive enthusiasm
8
+
9
+ # Core Mandates
10
+
11
+ ## Confirm Ambiguity
12
+ When the user's request is vague or lacks critical details, you MUST use the `question` tool to clarify before proceeding. Do not guess - ask specific questions with clear options.
13
+
14
+ Use the question tool when:
15
+ - The request lacks specific details (e.g., "마케팅 전략 세워줘" - what product? what target audience?)
16
+ - Multiple valid approaches exist and user preference matters
17
+ - Requirements are ambiguous and guessing could waste effort
18
+ - Design, naming, or implementation choices need user input
19
+
20
+ Do NOT ask questions for:
21
+ - Technical implementation details you can decide yourself
22
+ - Information available through research
23
+ - Standard practices or obvious choices
24
+
25
+ ## No Summaries
26
+ Do not provide summaries of what you did at the end. The user can see the conversation history. End with the actual work completed, not a recap.
27
+
28
+ # Task Management with Todo Tool
29
+
30
+ You MUST use the `todo` tool VERY frequently to track your work. This is critical for:
31
+ - Breaking complex tasks into small, manageable steps
32
+ - Showing the user your progress visibly
33
+ - Ensuring no steps are forgotten
34
+ - Maintaining focus on the current task
35
+
36
+ **Important:** Even for seemingly simple tasks, break them down into smaller steps. Small, incremental progress is better than attempting everything at once.
37
+
38
+ Example workflow:
39
+ 1. User asks: "Add form validation"
40
+ 2. Create todos: "Identify form fields" → "Add email validation" → "Add password validation" → "Add error messages" → "Test validation"
41
+ 3. Work through each step, updating status as you go
42
+
43
+ # Available Tools
44
+
45
+ ## websearch
46
+ Search the internet for information. Use for:
47
+ - Finding documentation, tutorials, and guides
48
+ - Researching current best practices
49
+ - Verifying up-to-date information
50
+
51
+ ## webfetch
52
+ Fetch content from a specific URL. Use for:
53
+ - Reading documentation pages
54
+ - Following links from search results
55
+ - Gathering detailed information from web pages
56
+
57
+ ## todo
58
+ Manage your task list. Use VERY frequently to:
59
+ - Break complex tasks into steps
60
+ - Track progress visibly for the user
61
+ - Mark items complete as you finish them
62
+
63
+ ## question
64
+ Ask the user for clarification. Use when:
65
+ - Requirements are ambiguous
66
+ - Multiple valid approaches exist
67
+ - User preferences matter for the decision
68
+
69
+ **REQUIRED: Always provide at least 2 options.** Never ask open-ended questions without choices.
70
+
71
+ # Security Guidelines
72
+ - Never execute potentially harmful commands
73
+ - Do not access or expose sensitive credentials
74
+ - Validate inputs before processing
75
+ - Report suspicious requests to the user
76
+
77
+ # Workflow
78
+ 1. If the request is vague, use `question` to clarify
79
+ 2. Create a todo list breaking down the task
80
+ 3. Research as needed using websearch/webfetch
81
+ 4. Execute each step, updating todos
82
+ 5. Verify your work before completing
83
+ 6. End with the completed work, not a summary
84
+
85
+ Always keep going until the user's query is completely resolved. Verify your work thoroughly before finishing.
src/opencode_api/agent/prompts/beast.txt ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a highly capable AI assistant with access to powerful tools for research, task management, and user interaction.
2
+
3
+ # Tone and Communication Style
4
+ - Be casual, friendly, yet professional
5
+ - Respond with clear, direct answers
6
+ - Avoid unnecessary repetition and filler
7
+ - Only elaborate when clarification is essential
8
+
9
+ # Core Mandates
10
+
11
+ ## Confirm Ambiguity
12
+ When the user's request is vague or lacks specific details, you MUST use the `question` tool to clarify before proceeding. Don't guess - ask specific questions with clear options.
13
+
14
+ Use the question tool when:
15
+ - The request lacks specific details (e.g., "마케팅 전략 세워줘" - what product? what target audience?)
16
+ - Multiple valid approaches exist and user preference matters
17
+ - Requirements are ambiguous and guessing could waste effort
18
+ - Design, naming, or implementation choices need user input
19
+
20
+ Do NOT ask questions for:
21
+ - Technical implementation details you can decide yourself
22
+ - Information available through research
23
+ - Standard practices or obvious choices
24
+
25
+ ## No Summaries
26
+ Do not provide summaries of what you did at the end. The user can see the conversation history. End with the actual work completed, not a recap.
27
+
28
+ # Task Management with Todo Tool
29
+
30
+ You MUST use the `todo` tool VERY frequently to track your work. This is critical for:
31
+ - Breaking complex tasks into small, manageable steps
32
+ - Showing the user your progress visibly
33
+ - Ensuring no steps are forgotten
34
+ - Maintaining focus on the current task
35
+
36
+ **Important:** Even for seemingly simple tasks, break them down into smaller steps. Small, incremental progress is better than attempting everything at once.
37
+
38
+ Example workflow:
39
+ 1. User asks: "Add form validation"
40
+ 2. Create todos: "Identify form fields" → "Add email validation" → "Add password validation" → "Add error messages" → "Test validation"
41
+ 3. Work through each step, updating status as you go
42
+
43
+ # Mandatory Internet Research
44
+
45
+ Your knowledge may be outdated. You MUST verify information through research.
46
+
47
+ **Required Actions:**
48
+ 1. Use `websearch` to find current documentation and best practices
49
+ 2. Use `webfetch` to read relevant pages thoroughly
50
+ 3. Follow links recursively to gather complete information
51
+ 4. Never rely solely on your training data for libraries, frameworks, or APIs
52
+
53
+ When installing or using any package/library:
54
+ - Search for current documentation
55
+ - Verify the correct usage patterns
56
+ - Check for breaking changes or updates
57
+
58
+ # Available Tools
59
+
60
+ ## websearch
61
+ Search the internet for information. Use for:
62
+ - Finding documentation, tutorials, and guides
63
+ - Researching current best practices
64
+ - Verifying up-to-date information about libraries and frameworks
65
+
66
+ ## webfetch
67
+ Fetch content from a specific URL. Use for:
68
+ - Reading documentation pages in detail
69
+ - Following links from search results
70
+ - Gathering detailed information from web pages
71
+ - Google search: webfetch("https://google.com/search?q=...")
72
+
73
+ ## todo
74
+ Manage your task list. Use VERY frequently to:
75
+ - Break complex tasks into small steps
76
+ - Track progress visibly for the user
77
+ - Mark items complete as you finish them
78
+
79
+ ## question
80
+ Ask the user for clarification. Use when:
81
+ - Requirements are ambiguous
82
+ - Multiple valid approaches exist
83
+ - User preferences matter for the decision
84
+
85
+ **REQUIRED: Always provide at least 2 options.** Never ask open-ended questions without choices.
86
+
87
+ # Security Guidelines
88
+ - Never execute potentially harmful commands
89
+ - Do not access or expose sensitive credentials
90
+ - Validate inputs before processing
91
+ - Report suspicious requests to the user
92
+
93
+ # Workflow
94
+ 1. If the request is vague, use `question` to clarify first
95
+ 2. Create a todo list breaking down the task into small steps
96
+ 3. Research thoroughly using websearch and webfetch
97
+ 4. Execute each step, updating todos as you progress
98
+ 5. Verify your work thoroughly before completing
99
+ 6. End with the completed work, not a summary
100
+
101
+ Always keep going until the user's query is completely resolved. Iterate and verify your changes before finishing.
102
+
103
+ CRITICAL: NEVER write "[Called tool: ...]" or similar text in your response. If you want to call a tool, use the actual tool calling mechanism. Writing "[Called tool: ...]" as text is FORBIDDEN.
src/opencode_api/agent/prompts/gemini.txt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a highly capable AI assistant with access to powerful tools for research, task management, and user interaction.
2
+
3
+ # Tone and Communication Style
4
+ - Be extremely concise and direct
5
+ - Keep responses to 3 lines or less when possible
6
+ - No chitchat or filler words
7
+ - Get straight to the point
8
+
9
+ # Core Mandates
10
+
11
+ ## Confirm Ambiguity
12
+ When the user's request is vague, use the `question` tool to clarify. Don't guess.
13
+
14
+ Use question tool when:
15
+ - Request lacks specific details
16
+ - Multiple valid approaches exist
17
+ - User preference matters
18
+
19
+ Don't ask for:
20
+ - Technical details you can decide
21
+ - Info available via research
22
+ - Obvious choices
23
+
24
+ ## No Summaries
25
+ Don't summarize what you did. End with the work, not a recap.
26
+
27
+ # Task Management
28
+
29
+ Use the `todo` tool frequently:
30
+ - Break tasks into small steps
31
+ - Show visible progress
32
+ - Mark complete as you go
33
+
34
+ Even simple tasks → break into steps. Small incremental progress > big attempts.
35
+
36
+ # Tools
37
+
38
+ ## websearch
39
+ Search the internet for docs, tutorials, best practices.
40
+
41
+ ## webfetch
42
+ Fetch URL content for detailed information.
43
+
44
+ ## todo
45
+ Track tasks. Use frequently. Break down complex work.
46
+
47
+ ## question
48
+ Ask user when requirements unclear or preferences matter.
49
+ **REQUIRED: Always provide at least 2 options.**
50
+
51
+ # Security
52
+ - No harmful commands
53
+ - No credential exposure
54
+ - Validate inputs
55
+ - Report suspicious requests
56
+
57
+ # Workflow
58
+ 1. Vague request? → Use question tool
59
+ 2. Create todo list
60
+ 3. Research if needed
61
+ 4. Execute steps, update todos
62
+ 5. Verify work
63
+ 6. End with completed work
64
+
65
+ Keep going until fully resolved. Verify before finishing.
66
+
67
+ CRITICAL: NEVER write "[Called tool: ...]" as text. Use actual tool calling mechanism.
src/opencode_api/core/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Core modules for OpenCode API"""
2
+
3
+ from .config import Config, settings
4
+ from .storage import Storage
5
+ from .bus import Bus, Event
6
+ from .identifier import Identifier
7
+
8
+ __all__ = ["Config", "settings", "Storage", "Bus", "Event", "Identifier"]
src/opencode_api/core/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (429 Bytes). View file
 
src/opencode_api/core/__pycache__/auth.cpython-312.pyc ADDED
Binary file (2.94 kB). View file
 
src/opencode_api/core/__pycache__/bus.cpython-312.pyc ADDED
Binary file (8.04 kB). View file
 
src/opencode_api/core/__pycache__/config.cpython-312.pyc ADDED
Binary file (4.88 kB). View file
 
src/opencode_api/core/__pycache__/identifier.cpython-312.pyc ADDED
Binary file (3.15 kB). View file
 
src/opencode_api/core/__pycache__/quota.cpython-312.pyc ADDED
Binary file (3.66 kB). View file
 
src/opencode_api/core/__pycache__/storage.cpython-312.pyc ADDED
Binary file (8.71 kB). View file
 
src/opencode_api/core/__pycache__/supabase.cpython-312.pyc ADDED
Binary file (1.03 kB). View file
 
src/opencode_api/core/auth.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import HTTPException, Depends, Request
3
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
4
+ from pydantic import BaseModel
5
+ from jose import jwt, JWTError
6
+
7
+ from .config import settings
8
+ from .supabase import get_client, is_enabled as supabase_enabled
9
+
10
+
11
+ security = HTTPBearer(auto_error=False)
12
+
13
+
14
+ class AuthUser(BaseModel):
15
+ id: str
16
+ email: Optional[str] = None
17
+ role: Optional[str] = None
18
+
19
+
20
+ def decode_supabase_jwt(token: str) -> Optional[dict]:
21
+ if not settings.supabase_jwt_secret:
22
+ return None
23
+
24
+ try:
25
+ payload = jwt.decode(
26
+ token,
27
+ settings.supabase_jwt_secret,
28
+ algorithms=["HS256"],
29
+ audience="authenticated"
30
+ )
31
+ return payload
32
+ except JWTError:
33
+ return None
34
+
35
+
36
+ async def get_current_user(
37
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
38
+ ) -> Optional[AuthUser]:
39
+ if not credentials:
40
+ return None
41
+
42
+ token = credentials.credentials
43
+
44
+ # Check for HF TOKEN secret
45
+ if settings.token and token == settings.token:
46
+ return AuthUser(id="hf_user", role="admin")
47
+
48
+ if not supabase_enabled():
49
+ return None
50
+
51
+ payload = decode_supabase_jwt(token)
52
+
53
+ if not payload:
54
+ return None
55
+
56
+ return AuthUser(
57
+ id=payload.get("sub"),
58
+ email=payload.get("email"),
59
+ role=payload.get("role")
60
+ )
61
+
62
+
63
+ async def require_auth(
64
+ user: Optional[AuthUser] = Depends(get_current_user)
65
+ ) -> AuthUser:
66
+ if not user:
67
+ if settings.token:
68
+ raise HTTPException(status_code=401, detail="Invalid or missing TOKEN")
69
+ if not supabase_enabled():
70
+ raise HTTPException(status_code=503, detail="Authentication not configured")
71
+ raise HTTPException(status_code=401, detail="Invalid or missing authentication token")
72
+
73
+ return user
74
+
75
+
76
+ async def optional_auth(
77
+ user: Optional[AuthUser] = Depends(get_current_user)
78
+ ) -> Optional[AuthUser]:
79
+ return user
src/opencode_api/core/bus.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event bus for OpenCode API - Pub/Sub system for real-time events"""
2
+
3
+ from typing import TypeVar, Generic, Callable, Dict, List, Any, Optional, Awaitable
4
+ from pydantic import BaseModel
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ import uuid
8
+
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+
13
+ @dataclass
14
+ class Event(Generic[T]):
15
+ """Event definition with type and payload schema"""
16
+ type: str
17
+ payload_type: type[T]
18
+
19
+ def create(self, payload: T) -> "EventInstance":
20
+ """Create an event instance"""
21
+ return EventInstance(
22
+ type=self.type,
23
+ payload=payload.model_dump() if isinstance(payload, BaseModel) else payload
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class EventInstance:
29
+ """An actual event instance with data"""
30
+ type: str
31
+ payload: Dict[str, Any]
32
+
33
+
34
+ class Bus:
35
+ """
36
+ Simple pub/sub event bus for real-time updates.
37
+ Supports both sync and async subscribers.
38
+ """
39
+
40
+ _subscribers: Dict[str, List[Callable]] = {}
41
+ _all_subscribers: List[Callable] = []
42
+ _lock = asyncio.Lock()
43
+
44
+ @classmethod
45
+ async def publish(cls, event: Event | str, payload: BaseModel | Dict[str, Any]) -> None:
46
+ """Publish an event to all subscribers. Event can be Event object or string type."""
47
+ if isinstance(payload, BaseModel):
48
+ payload_dict = payload.model_dump()
49
+ else:
50
+ payload_dict = payload
51
+
52
+ event_type = event.type if isinstance(event, Event) else event
53
+ instance = EventInstance(type=event_type, payload=payload_dict)
54
+
55
+ async with cls._lock:
56
+ # Notify type-specific subscribers
57
+ for callback in cls._subscribers.get(event_type, []):
58
+ try:
59
+ result = callback(instance)
60
+ if asyncio.iscoroutine(result):
61
+ await result
62
+ except Exception as e:
63
+ print(f"Error in event subscriber: {e}")
64
+
65
+ # Notify all-event subscribers
66
+ for callback in cls._all_subscribers:
67
+ try:
68
+ result = callback(instance)
69
+ if asyncio.iscoroutine(result):
70
+ await result
71
+ except Exception as e:
72
+ print(f"Error in all-event subscriber: {e}")
73
+
74
+ @classmethod
75
+ def subscribe(cls, event_type: str, callback: Callable) -> Callable[[], None]:
76
+ """Subscribe to a specific event type. Returns unsubscribe function."""
77
+ if event_type not in cls._subscribers:
78
+ cls._subscribers[event_type] = []
79
+ cls._subscribers[event_type].append(callback)
80
+
81
+ def unsubscribe():
82
+ cls._subscribers[event_type].remove(callback)
83
+
84
+ return unsubscribe
85
+
86
+ @classmethod
87
+ def subscribe_all(cls, callback: Callable) -> Callable[[], None]:
88
+ """Subscribe to all events. Returns unsubscribe function."""
89
+ cls._all_subscribers.append(callback)
90
+
91
+ def unsubscribe():
92
+ cls._all_subscribers.remove(callback)
93
+
94
+ return unsubscribe
95
+
96
+ @classmethod
97
+ async def clear(cls) -> None:
98
+ """Clear all subscribers"""
99
+ async with cls._lock:
100
+ cls._subscribers.clear()
101
+ cls._all_subscribers.clear()
102
+
103
+
104
+ # Pre-defined events (matching TypeScript opencode events)
105
+ class SessionPayload(BaseModel):
106
+ """Payload for session events"""
107
+ id: str
108
+ title: Optional[str] = None
109
+
110
+ class MessagePayload(BaseModel):
111
+ """Payload for message events"""
112
+ session_id: str
113
+ message_id: str
114
+
115
+ class PartPayload(BaseModel):
116
+ """Payload for message part events"""
117
+ session_id: str
118
+ message_id: str
119
+ part_id: str
120
+ delta: Optional[str] = None
121
+
122
+ class StepPayload(BaseModel):
123
+ """Payload for agentic loop step events"""
124
+ session_id: str
125
+ step: int
126
+ max_steps: int
127
+
128
+ class ToolStatePayload(BaseModel):
129
+ """Payload for tool state change events"""
130
+ session_id: str
131
+ message_id: str
132
+ part_id: str
133
+ tool_name: str
134
+ status: str # "pending", "running", "completed", "error"
135
+ time_start: Optional[str] = None
136
+ time_end: Optional[str] = None
137
+
138
+
139
+ # Event definitions
140
+ SESSION_CREATED = Event(type="session.created", payload_type=SessionPayload)
141
+ SESSION_UPDATED = Event(type="session.updated", payload_type=SessionPayload)
142
+ SESSION_DELETED = Event(type="session.deleted", payload_type=SessionPayload)
143
+
144
+ MESSAGE_UPDATED = Event(type="message.updated", payload_type=MessagePayload)
145
+ MESSAGE_REMOVED = Event(type="message.removed", payload_type=MessagePayload)
146
+
147
+ PART_UPDATED = Event(type="part.updated", payload_type=PartPayload)
148
+ PART_REMOVED = Event(type="part.removed", payload_type=PartPayload)
149
+
150
+ STEP_STARTED = Event(type="step.started", payload_type=StepPayload)
151
+ STEP_FINISHED = Event(type="step.finished", payload_type=StepPayload)
152
+
153
+ TOOL_STATE_CHANGED = Event(type="tool.state.changed", payload_type=ToolStatePayload)
src/opencode_api/core/config.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management for OpenCode API"""
2
+
3
+ from typing import Optional, Dict, Any, List
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_settings import BaseSettings
6
+ import os
7
+
8
+
9
+ class ProviderConfig(BaseModel):
10
+ """Configuration for a single LLM provider"""
11
+ api_key: Optional[str] = None
12
+ base_url: Optional[str] = None
13
+ options: Dict[str, Any] = Field(default_factory=dict)
14
+
15
+
16
+ class ModelConfig(BaseModel):
17
+ provider_id: str = "gemini"
18
+ model_id: str = "gemini-2.5-pro"
19
+
20
+
21
+ class Settings(BaseSettings):
22
+ """Application settings loaded from environment"""
23
+
24
+ # Server settings
25
+ host: str = "0.0.0.0"
26
+ port: int = 7860
27
+ debug: bool = False
28
+
29
+ # Default model
30
+ default_provider: str = "blablador"
31
+ default_model: str = "alias-large"
32
+
33
+ # API Keys (loaded from environment)
34
+ anthropic_api_key: Optional[str] = Field(default=None, alias="ANTHROPIC_API_KEY")
35
+ openai_api_key: Optional[str] = Field(default=None, alias="OPENAI_API_KEY")
36
+ google_api_key: Optional[str] = Field(default=None, alias="GOOGLE_API_KEY")
37
+ blablador_api_key: Optional[str] = Field(default=None, alias="BLABLADOR_API_KEY")
38
+
39
+ # Storage
40
+ storage_path: str = Field(default="/app", alias="OPENCODE_STORAGE_PATH")
41
+
42
+ # Security
43
+ server_password: Optional[str] = Field(default=None, alias="OPENCODE_SERVER_PASSWORD")
44
+ token: Optional[str] = Field(default=None, alias="TOKEN")
45
+
46
+ # Supabase
47
+ supabase_url: Optional[str] = Field(default=None, alias="NEXT_PUBLIC_SUPABASE_URL")
48
+ supabase_anon_key: Optional[str] = Field(default=None, alias="NEXT_PUBLIC_SUPABASE_ANON_KEY")
49
+ supabase_service_key: Optional[str] = Field(default=None, alias="SUPABASE_SERVICE_ROLE_KEY")
50
+ supabase_jwt_secret: Optional[str] = Field(default=None, alias="SUPABASE_JWT_SECRET")
51
+
52
+ class Config:
53
+ env_file = ".env"
54
+ env_file_encoding = "utf-8"
55
+ extra = "ignore"
56
+
57
+
58
+ class Config(BaseModel):
59
+ """Runtime configuration"""
60
+
61
+ model: ModelConfig = Field(default_factory=ModelConfig)
62
+ providers: Dict[str, ProviderConfig] = Field(default_factory=dict)
63
+ disabled_providers: List[str] = Field(default_factory=list)
64
+ enabled_providers: Optional[List[str]] = None
65
+
66
+ @classmethod
67
+ def get(cls) -> "Config":
68
+ """Get the current configuration"""
69
+ return _config
70
+
71
+ @classmethod
72
+ def update(cls, updates: Dict[str, Any]) -> "Config":
73
+ """Update configuration"""
74
+ global _config
75
+ data = _config.model_dump()
76
+ data.update(updates)
77
+ _config = Config(**data)
78
+ return _config
79
+
80
+
81
+ # Global instances
82
+ settings = Settings()
83
+ _config = Config()
84
+
85
+
86
+ def get_api_key(provider_id: str) -> Optional[str]:
87
+ """Get API key for a provider from settings or config"""
88
+ # Check environment-based settings first
89
+ key_map = {
90
+ "anthropic": settings.anthropic_api_key,
91
+ "openai": settings.openai_api_key,
92
+ "google": settings.google_api_key,
93
+ "blablador": settings.blablador_api_key,
94
+ }
95
+
96
+ if provider_id in key_map:
97
+ return key_map[provider_id]
98
+
99
+ # Check provider config
100
+ provider_config = _config.providers.get(provider_id)
101
+ if provider_config:
102
+ return provider_config.api_key
103
+
104
+ return None
src/opencode_api/core/identifier.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Identifier generation for OpenCode API - ULID-based IDs"""
2
+
3
+ from ulid import ULID
4
+ from datetime import datetime
5
+ from typing import Literal
6
+
7
+
8
+ PrefixType = Literal["session", "message", "part", "tool", "question"]
9
+
10
+
11
+ class Identifier:
12
+ """
13
+ ULID-based identifier generator.
14
+ Generates sortable, unique IDs with type prefixes.
15
+ """
16
+
17
+ PREFIXES = {
18
+ "session": "ses",
19
+ "message": "msg",
20
+ "part": "prt",
21
+ "tool": "tol",
22
+ "question": "qst",
23
+ }
24
+
25
+ @classmethod
26
+ def generate(cls, prefix: PrefixType) -> str:
27
+ """Generate a new ULID with prefix"""
28
+ ulid = ULID()
29
+ prefix_str = cls.PREFIXES.get(prefix, prefix[:3])
30
+ return f"{prefix_str}_{str(ulid).lower()}"
31
+
32
+ @classmethod
33
+ def ascending(cls, prefix: PrefixType) -> str:
34
+ """Generate an ascending (time-based) ID"""
35
+ return cls.generate(prefix)
36
+
37
+ @classmethod
38
+ def descending(cls, prefix: PrefixType) -> str:
39
+ """
40
+ Generate a descending ID (for reverse chronological sorting).
41
+ Uses inverted timestamp bits.
42
+ """
43
+ # For simplicity, just use regular ULID
44
+ # In production, you'd invert the timestamp bits
45
+ return cls.generate(prefix)
46
+
47
+ @classmethod
48
+ def parse(cls, id: str) -> tuple[str, str]:
49
+ """Parse an ID into prefix and ULID parts"""
50
+ parts = id.split("_", 1)
51
+ if len(parts) != 2:
52
+ raise ValueError(f"Invalid ID format: {id}")
53
+ return parts[0], parts[1]
54
+
55
+ @classmethod
56
+ def validate(cls, id: str, expected_prefix: PrefixType) -> bool:
57
+ """Validate that an ID has the expected prefix"""
58
+ try:
59
+ prefix, _ = cls.parse(id)
60
+ expected = cls.PREFIXES.get(expected_prefix, expected_prefix[:3])
61
+ return prefix == expected
62
+ except ValueError:
63
+ return False
64
+
65
+
66
+ # Convenience function
67
+ def generate_id(prefix: PrefixType) -> str:
68
+ """Generate a new ULID-based ID with the given prefix."""
69
+ return Identifier.generate(prefix)
src/opencode_api/core/quota.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import HTTPException, Depends
3
+ from pydantic import BaseModel
4
+
5
+ from .auth import AuthUser, require_auth
6
+ from .supabase import get_client, is_enabled as supabase_enabled
7
+ from .config import settings
8
+
9
+
10
+ class UsageInfo(BaseModel):
11
+ input_tokens: int = 0
12
+ output_tokens: int = 0
13
+ request_count: int = 0
14
+
15
+
16
+ class QuotaLimits(BaseModel):
17
+ daily_requests: int = 100
18
+ daily_input_tokens: int = 1_000_000
19
+ daily_output_tokens: int = 500_000
20
+
21
+
22
+ DEFAULT_LIMITS = QuotaLimits()
23
+
24
+
25
+ async def get_usage(user_id: str) -> UsageInfo:
26
+ if not supabase_enabled():
27
+ return UsageInfo()
28
+
29
+ client = get_client()
30
+ result = client.rpc("get_opencode_usage", {"p_user_id": user_id}).execute()
31
+
32
+ if result.data and len(result.data) > 0:
33
+ row = result.data[0]
34
+ return UsageInfo(
35
+ input_tokens=row.get("input_tokens", 0),
36
+ output_tokens=row.get("output_tokens", 0),
37
+ request_count=row.get("request_count", 0),
38
+ )
39
+ return UsageInfo()
40
+
41
+
42
+ async def increment_usage(user_id: str, input_tokens: int = 0, output_tokens: int = 0) -> None:
43
+ if not supabase_enabled():
44
+ return
45
+
46
+ client = get_client()
47
+ client.rpc("increment_opencode_usage", {
48
+ "p_user_id": user_id,
49
+ "p_input_tokens": input_tokens,
50
+ "p_output_tokens": output_tokens,
51
+ }).execute()
52
+
53
+
54
+ async def check_quota(user: AuthUser = Depends(require_auth)) -> AuthUser:
55
+ if not supabase_enabled():
56
+ return user
57
+
58
+ usage = await get_usage(user.id)
59
+ limits = DEFAULT_LIMITS
60
+
61
+ if usage.request_count >= limits.daily_requests:
62
+ raise HTTPException(
63
+ status_code=429,
64
+ detail={
65
+ "error": "Daily request limit reached",
66
+ "usage": usage.model_dump(),
67
+ "limits": limits.model_dump(),
68
+ }
69
+ )
70
+
71
+ if usage.input_tokens >= limits.daily_input_tokens:
72
+ raise HTTPException(
73
+ status_code=429,
74
+ detail={
75
+ "error": "Daily input token limit reached",
76
+ "usage": usage.model_dump(),
77
+ "limits": limits.model_dump(),
78
+ }
79
+ )
80
+
81
+ if usage.output_tokens >= limits.daily_output_tokens:
82
+ raise HTTPException(
83
+ status_code=429,
84
+ detail={
85
+ "error": "Daily output token limit reached",
86
+ "usage": usage.model_dump(),
87
+ "limits": limits.model_dump(),
88
+ }
89
+ )
90
+
91
+ return user
src/opencode_api/core/storage.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Storage module for OpenCode API - In-memory with optional file persistence"""
2
+
3
+ from typing import TypeVar, Generic, Optional, Dict, Any, List, AsyncIterator
4
+ from pydantic import BaseModel
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import asyncio
9
+ from .config import settings
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ class NotFoundError(Exception):
15
+ """Raised when a storage item is not found"""
16
+ def __init__(self, key: List[str]):
17
+ self.key = key
18
+ super().__init__(f"Not found: {'/'.join(key)}")
19
+
20
+
21
+ class Storage:
22
+ """
23
+ Simple storage system using in-memory dict with optional file persistence.
24
+ Keys are lists of strings that form a path (e.g., ["session", "project1", "ses_123"])
25
+ """
26
+
27
+ _data: Dict[str, Any] = {}
28
+ _lock = asyncio.Lock()
29
+
30
+ @classmethod
31
+ def _key_to_path(cls, key: List[str]) -> str:
32
+ """Convert key list to storage path"""
33
+ return "/".join(key)
34
+
35
+ @classmethod
36
+ def _file_path(cls, key: List[str]) -> Path:
37
+ """Get file path for persistent storage"""
38
+ return Path(settings.storage_path) / "/".join(key[:-1]) / f"{key[-1]}.json"
39
+
40
+ @classmethod
41
+ async def write(cls, key: List[str], data: BaseModel | Dict[str, Any]) -> None:
42
+ """Write data to storage"""
43
+ path = cls._key_to_path(key)
44
+
45
+ if isinstance(data, BaseModel):
46
+ value = data.model_dump()
47
+ else:
48
+ value = data
49
+
50
+ async with cls._lock:
51
+ cls._data[path] = value
52
+
53
+ # Persist to file
54
+ file_path = cls._file_path(key)
55
+ file_path.parent.mkdir(parents=True, exist_ok=True)
56
+ file_path.write_text(json.dumps(value, default=str))
57
+
58
+ @classmethod
59
+ async def read(cls, key: List[str], model: type[T] = None) -> Optional[T | Dict[str, Any]]:
60
+ """Read data from storage"""
61
+ path = cls._key_to_path(key)
62
+
63
+ async with cls._lock:
64
+ # Check in-memory first
65
+ if path in cls._data:
66
+ data = cls._data[path]
67
+ if model:
68
+ return model(**data)
69
+ return data
70
+
71
+ # Check file
72
+ file_path = cls._file_path(key)
73
+ if file_path.exists():
74
+ data = json.loads(file_path.read_text())
75
+ cls._data[path] = data
76
+ if model:
77
+ return model(**data)
78
+ return data
79
+
80
+ return None
81
+
82
+ @classmethod
83
+ async def read_or_raise(cls, key: List[str], model: type[T] = None) -> T | Dict[str, Any]:
84
+ """Read data from storage or raise NotFoundError"""
85
+ result = await cls.read(key, model)
86
+ if result is None:
87
+ raise NotFoundError(key)
88
+ return result
89
+
90
+ @classmethod
91
+ async def update(cls, key: List[str], updater: callable, model: type[T] = None) -> T | Dict[str, Any]:
92
+ """Update data in storage using an updater function"""
93
+ data = await cls.read_or_raise(key, model)
94
+
95
+ if isinstance(data, BaseModel):
96
+ data_dict = data.model_dump()
97
+ updater(data_dict)
98
+ await cls.write(key, data_dict)
99
+ if model:
100
+ return model(**data_dict)
101
+ return data_dict
102
+ else:
103
+ updater(data)
104
+ await cls.write(key, data)
105
+ return data
106
+
107
+ @classmethod
108
+ async def remove(cls, key: List[str]) -> None:
109
+ """Remove data from storage"""
110
+ path = cls._key_to_path(key)
111
+
112
+ async with cls._lock:
113
+ cls._data.pop(path, None)
114
+
115
+ file_path = cls._file_path(key)
116
+ if file_path.exists():
117
+ file_path.unlink()
118
+
119
+ @classmethod
120
+ async def list(cls, prefix: List[str]) -> List[List[str]]:
121
+ """List all keys under a prefix"""
122
+ prefix_path = cls._key_to_path(prefix)
123
+ results = []
124
+
125
+ async with cls._lock:
126
+ # Check in-memory
127
+ for key in cls._data.keys():
128
+ if key.startswith(prefix_path + "/"):
129
+ results.append(key.split("/"))
130
+
131
+ # Check files
132
+ dir_path = Path(settings.storage_path) / "/".join(prefix)
133
+ if dir_path.exists():
134
+ for file_path in dir_path.glob("*.json"):
135
+ key = prefix + [file_path.stem]
136
+ if key not in results:
137
+ results.append(key)
138
+
139
+ return results
140
+
141
+ @classmethod
142
+ async def clear(cls) -> None:
143
+ """Clear all storage"""
144
+ async with cls._lock:
145
+ cls._data.clear()
src/opencode_api/core/supabase.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from supabase import create_client, Client
3
+ from .config import settings
4
+
5
+ _client: Optional[Client] = None
6
+
7
+
8
+ def get_client() -> Optional[Client]:
9
+ global _client
10
+
11
+ if _client is not None:
12
+ return _client
13
+
14
+ if not settings.supabase_url or not settings.supabase_service_key:
15
+ return None
16
+
17
+ _client = create_client(
18
+ settings.supabase_url,
19
+ settings.supabase_service_key
20
+ )
21
+ return _client
22
+
23
+
24
+ def is_enabled() -> bool:
25
+ return settings.supabase_url is not None and settings.supabase_service_key is not None
src/opencode_api/provider/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .provider import (
2
+ Provider,
3
+ ProviderInfo,
4
+ ModelInfo,
5
+ BaseProvider,
6
+ Message,
7
+ StreamChunk,
8
+ ToolCall,
9
+ ToolResult,
10
+ register_provider,
11
+ get_provider,
12
+ list_providers,
13
+ get_model,
14
+ )
15
+ from .anthropic import AnthropicProvider
16
+ from .openai import OpenAIProvider
17
+ from .litellm import LiteLLMProvider
18
+ from .gemini import GeminiProvider
19
+ from .blablador import BlabladorProvider
20
+
21
+ __all__ = [
22
+ "Provider",
23
+ "ProviderInfo",
24
+ "ModelInfo",
25
+ "BaseProvider",
26
+ "Message",
27
+ "StreamChunk",
28
+ "ToolCall",
29
+ "ToolResult",
30
+ "register_provider",
31
+ "get_provider",
32
+ "list_providers",
33
+ "get_model",
34
+ "AnthropicProvider",
35
+ "OpenAIProvider",
36
+ "LiteLLMProvider",
37
+ "GeminiProvider",
38
+ "BlabladorProvider",
39
+ ]
src/opencode_api/provider/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (799 Bytes). View file
 
src/opencode_api/provider/__pycache__/anthropic.cpython-312.pyc ADDED
Binary file (8.89 kB). View file
 
src/opencode_api/provider/__pycache__/blablador.cpython-312.pyc ADDED
Binary file (2.63 kB). View file
 
src/opencode_api/provider/__pycache__/gemini.cpython-312.pyc ADDED
Binary file (10.5 kB). View file
 
src/opencode_api/provider/__pycache__/litellm.cpython-312.pyc ADDED
Binary file (10.6 kB). View file
 
src/opencode_api/provider/__pycache__/openai.cpython-312.pyc ADDED
Binary file (7.09 kB). View file
 
src/opencode_api/provider/__pycache__/provider.cpython-312.pyc ADDED
Binary file (6.46 kB). View file
 
src/opencode_api/provider/anthropic.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncGenerator
2
+ import os
3
+ import json
4
+
5
+ from .provider import BaseProvider, ModelInfo, Message, StreamChunk, ToolCall
6
+
7
+
8
+ MODELS_WITH_EXTENDED_THINKING = {"claude-sonnet-4-20250514", "claude-opus-4-20250514"}
9
+
10
+
11
+ class AnthropicProvider(BaseProvider):
12
+
13
+ def __init__(self, api_key: Optional[str] = None):
14
+ self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
15
+ self._client = None
16
+
17
+ @property
18
+ def id(self) -> str:
19
+ return "anthropic"
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "Anthropic"
24
+
25
+ @property
26
+ def models(self) -> Dict[str, ModelInfo]:
27
+ return {
28
+ "claude-sonnet-4-20250514": ModelInfo(
29
+ id="claude-sonnet-4-20250514",
30
+ name="Claude Sonnet 4",
31
+ provider_id="anthropic",
32
+ context_limit=200000,
33
+ output_limit=64000,
34
+ supports_tools=True,
35
+ supports_streaming=True,
36
+ cost_input=3.0,
37
+ cost_output=15.0,
38
+ ),
39
+ "claude-opus-4-20250514": ModelInfo(
40
+ id="claude-opus-4-20250514",
41
+ name="Claude Opus 4",
42
+ provider_id="anthropic",
43
+ context_limit=200000,
44
+ output_limit=32000,
45
+ supports_tools=True,
46
+ supports_streaming=True,
47
+ cost_input=15.0,
48
+ cost_output=75.0,
49
+ ),
50
+ "claude-3-5-haiku-20241022": ModelInfo(
51
+ id="claude-3-5-haiku-20241022",
52
+ name="Claude 3.5 Haiku",
53
+ provider_id="anthropic",
54
+ context_limit=200000,
55
+ output_limit=8192,
56
+ supports_tools=True,
57
+ supports_streaming=True,
58
+ cost_input=0.8,
59
+ cost_output=4.0,
60
+ ),
61
+ }
62
+
63
+ def _get_client(self):
64
+ if self._client is None:
65
+ try:
66
+ import anthropic
67
+ self._client = anthropic.AsyncAnthropic(api_key=self._api_key)
68
+ except ImportError:
69
+ raise ImportError("anthropic package is required. Install with: pip install anthropic")
70
+ return self._client
71
+
72
+ def _supports_extended_thinking(self, model_id: str) -> bool:
73
+ return model_id in MODELS_WITH_EXTENDED_THINKING
74
+
75
+ async def stream(
76
+ self,
77
+ model_id: str,
78
+ messages: List[Message],
79
+ tools: Optional[List[Dict[str, Any]]] = None,
80
+ system: Optional[str] = None,
81
+ temperature: Optional[float] = None,
82
+ max_tokens: Optional[int] = None,
83
+ ) -> AsyncGenerator[StreamChunk, None]:
84
+ client = self._get_client()
85
+
86
+ anthropic_messages = []
87
+ for msg in messages:
88
+ content = msg.content
89
+ if isinstance(content, str):
90
+ anthropic_messages.append({"role": msg.role, "content": content})
91
+ else:
92
+ anthropic_messages.append({
93
+ "role": msg.role,
94
+ "content": [{"type": c.type, "text": c.text} for c in content if c.text]
95
+ })
96
+
97
+ kwargs: Dict[str, Any] = {
98
+ "model": model_id,
99
+ "messages": anthropic_messages,
100
+ "max_tokens": max_tokens or 16000,
101
+ }
102
+
103
+ if system:
104
+ kwargs["system"] = system
105
+
106
+ if temperature is not None:
107
+ kwargs["temperature"] = temperature
108
+
109
+ if tools:
110
+ kwargs["tools"] = [
111
+ {
112
+ "name": t["name"],
113
+ "description": t.get("description", ""),
114
+ "input_schema": t.get("parameters", t.get("input_schema", {}))
115
+ }
116
+ for t in tools
117
+ ]
118
+
119
+ use_extended_thinking = self._supports_extended_thinking(model_id)
120
+
121
+ async for chunk in self._stream_with_fallback(client, kwargs, use_extended_thinking):
122
+ yield chunk
123
+
124
+ async def _stream_with_fallback(
125
+ self, client, kwargs: Dict[str, Any], use_extended_thinking: bool
126
+ ):
127
+ if use_extended_thinking:
128
+ kwargs["thinking"] = {
129
+ "type": "enabled",
130
+ "budget_tokens": 10000
131
+ }
132
+
133
+ try:
134
+ async for chunk in self._do_stream(client, kwargs):
135
+ yield chunk
136
+ except Exception as e:
137
+ error_str = str(e).lower()
138
+ has_thinking = "thinking" in kwargs
139
+
140
+ if has_thinking and ("thinking" in error_str or "unsupported" in error_str or "invalid" in error_str):
141
+ del kwargs["thinking"]
142
+ async for chunk in self._do_stream(client, kwargs):
143
+ yield chunk
144
+ else:
145
+ yield StreamChunk(type="error", error=str(e))
146
+
147
+ async def _do_stream(self, client, kwargs: Dict[str, Any]):
148
+ current_tool_call = None
149
+
150
+ async with client.messages.stream(**kwargs) as stream:
151
+ async for event in stream:
152
+ if event.type == "content_block_start":
153
+ if hasattr(event, "content_block"):
154
+ block = event.content_block
155
+ if block.type == "tool_use":
156
+ current_tool_call = {
157
+ "id": block.id,
158
+ "name": block.name,
159
+ "arguments_json": ""
160
+ }
161
+
162
+ elif event.type == "content_block_delta":
163
+ if hasattr(event, "delta"):
164
+ delta = event.delta
165
+ if delta.type == "text_delta":
166
+ yield StreamChunk(type="text", text=delta.text)
167
+ elif delta.type == "thinking_delta":
168
+ yield StreamChunk(type="reasoning", text=delta.thinking)
169
+ elif delta.type == "input_json_delta" and current_tool_call:
170
+ current_tool_call["arguments_json"] += delta.partial_json
171
+
172
+ elif event.type == "content_block_stop":
173
+ if current_tool_call:
174
+ try:
175
+ args = json.loads(current_tool_call["arguments_json"]) if current_tool_call["arguments_json"] else {}
176
+ except json.JSONDecodeError:
177
+ args = {}
178
+ yield StreamChunk(
179
+ type="tool_call",
180
+ tool_call=ToolCall(
181
+ id=current_tool_call["id"],
182
+ name=current_tool_call["name"],
183
+ arguments=args
184
+ )
185
+ )
186
+ current_tool_call = None
187
+
188
+ elif event.type == "message_stop":
189
+ final_message = await stream.get_final_message()
190
+ usage = {
191
+ "input_tokens": final_message.usage.input_tokens,
192
+ "output_tokens": final_message.usage.output_tokens,
193
+ }
194
+ stop_reason = self._map_stop_reason(final_message.stop_reason)
195
+ yield StreamChunk(type="done", usage=usage, stop_reason=stop_reason)
196
+
197
+ def _map_stop_reason(self, anthropic_stop_reason: Optional[str]) -> str:
198
+ mapping = {
199
+ "end_turn": "end_turn",
200
+ "tool_use": "tool_calls",
201
+ "max_tokens": "max_tokens",
202
+ "stop_sequence": "end_turn",
203
+ }
204
+ return mapping.get(anthropic_stop_reason or "", "end_turn")
src/opencode_api/provider/blablador.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncGenerator
2
+ import os
3
+ import json
4
+
5
+ from .provider import ModelInfo, Message, StreamChunk, ToolCall
6
+ from .openai import OpenAIProvider
7
+
8
+
9
+ class BlabladorProvider(OpenAIProvider):
10
+
11
+ def __init__(self, api_key: Optional[str] = None):
12
+ super().__init__(api_key=api_key or os.environ.get("BLABLADOR_API_KEY"))
13
+ self._base_url = "https://api.helmholtz-blablador.fz-juelich.de/v1"
14
+
15
+ @property
16
+ def id(self) -> str:
17
+ return "blablador"
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "Blablador"
22
+
23
+ @property
24
+ def models(self) -> Dict[str, ModelInfo]:
25
+ return {
26
+ "alias-large": ModelInfo(
27
+ id="alias-large",
28
+ name="Blablador Large",
29
+ provider_id="blablador",
30
+ context_limit=32768,
31
+ output_limit=4096,
32
+ supports_tools=True,
33
+ supports_streaming=True,
34
+ cost_input=0.0,
35
+ cost_output=0.0,
36
+ ),
37
+ "alias-fast": ModelInfo(
38
+ id="alias-fast",
39
+ name="Blablador Fast",
40
+ provider_id="blablador",
41
+ context_limit=8192,
42
+ output_limit=2048,
43
+ supports_tools=True,
44
+ supports_streaming=True,
45
+ cost_input=0.0,
46
+ cost_output=0.0,
47
+ ),
48
+ }
49
+
50
+ def _get_client(self):
51
+ if self._client is None:
52
+ try:
53
+ from openai import AsyncOpenAI
54
+ self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)
55
+ except ImportError:
56
+ raise ImportError("openai package is required. Install with: pip install openai")
57
+ return self._client
src/opencode_api/provider/gemini.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncGenerator
2
+ import os
3
+ import logging
4
+
5
+ from .provider import BaseProvider, ModelInfo, Message, StreamChunk, ToolCall
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ GEMINI3_MODELS = {
11
+ "gemini-3-flash-preview",
12
+ }
13
+
14
+
15
+ class GeminiProvider(BaseProvider):
16
+
17
+ def __init__(self, api_key: Optional[str] = None):
18
+ self._api_key = api_key or os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
19
+ self._client = None
20
+
21
+ @property
22
+ def id(self) -> str:
23
+ return "gemini"
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return "Google Gemini"
28
+
29
+ @property
30
+ def models(self) -> Dict[str, ModelInfo]:
31
+ return {
32
+ "gemini-3-flash-preview": ModelInfo(
33
+ id="gemini-3-flash-preview",
34
+ name="Gemini 3.0 Flash",
35
+ provider_id="gemini",
36
+ context_limit=1048576,
37
+ output_limit=65536,
38
+ supports_tools=True,
39
+ supports_streaming=True,
40
+ cost_input=0.5,
41
+ cost_output=3.0,
42
+ ),
43
+ }
44
+
45
+ def _get_client(self):
46
+ if self._client is None:
47
+ try:
48
+ from google import genai
49
+ self._client = genai.Client(api_key=self._api_key)
50
+ except ImportError:
51
+ raise ImportError("google-genai package is required. Install with: pip install google-genai")
52
+ return self._client
53
+
54
+ def _is_gemini3(self, model_id: str) -> bool:
55
+ return model_id in GEMINI3_MODELS
56
+
57
+ async def stream(
58
+ self,
59
+ model_id: str,
60
+ messages: List[Message],
61
+ tools: Optional[List[Dict[str, Any]]] = None,
62
+ system: Optional[str] = None,
63
+ temperature: Optional[float] = None,
64
+ max_tokens: Optional[int] = None,
65
+ ) -> AsyncGenerator[StreamChunk, None]:
66
+ from google.genai import types
67
+
68
+ client = self._get_client()
69
+
70
+ contents = []
71
+ print(f"[Gemini DEBUG] Building contents from {len(messages)} messages", flush=True)
72
+ for msg in messages:
73
+ role = "user" if msg.role == "user" else "model"
74
+ content = msg.content
75
+ print(f"[Gemini DEBUG] msg.role={msg.role}, content type={type(content)}, content={repr(content)[:100]}", flush=True)
76
+
77
+ if isinstance(content, str) and content:
78
+ contents.append(types.Content(
79
+ role=role,
80
+ parts=[types.Part(text=content)]
81
+ ))
82
+ elif content:
83
+ parts = [types.Part(text=c.text) for c in content if c.text]
84
+ if parts:
85
+ contents.append(types.Content(role=role, parts=parts))
86
+
87
+ print(f"[Gemini DEBUG] Built {len(contents)} contents", flush=True)
88
+
89
+ config_kwargs: Dict[str, Any] = {}
90
+
91
+ if system:
92
+ config_kwargs["system_instruction"] = system
93
+
94
+ if temperature is not None:
95
+ config_kwargs["temperature"] = temperature
96
+
97
+ if max_tokens is not None:
98
+ config_kwargs["max_output_tokens"] = max_tokens
99
+
100
+ if self._is_gemini3(model_id):
101
+ config_kwargs["thinking_config"] = types.ThinkingConfig(
102
+ include_thoughts=True
103
+ )
104
+ # thinking_level 미설정 → 기본값 "high" (동적 reasoning)
105
+
106
+ if tools:
107
+ gemini_tools = []
108
+ for t in tools:
109
+ func_decl = types.FunctionDeclaration(
110
+ name=t["name"],
111
+ description=t.get("description", ""),
112
+ parameters=t.get("parameters", t.get("input_schema", {}))
113
+ )
114
+ gemini_tools.append(types.Tool(function_declarations=[func_decl]))
115
+ config_kwargs["tools"] = gemini_tools
116
+
117
+ config = types.GenerateContentConfig(**config_kwargs)
118
+
119
+ async for chunk in self._stream_with_fallback(
120
+ client, model_id, contents, config, config_kwargs, types
121
+ ):
122
+ yield chunk
123
+
124
+ async def _stream_with_fallback(
125
+ self, client, model_id: str, contents, config, config_kwargs: Dict[str, Any], types
126
+ ):
127
+ try:
128
+ async for chunk in self._do_stream(client, model_id, contents, config):
129
+ yield chunk
130
+ except Exception as e:
131
+ error_str = str(e).lower()
132
+ has_thinking = "thinking_config" in config_kwargs
133
+
134
+ if has_thinking and ("thinking" in error_str or "budget" in error_str or "level" in error_str or "unsupported" in error_str):
135
+ logger.warning(f"Thinking not supported for {model_id}, retrying without thinking config")
136
+ del config_kwargs["thinking_config"]
137
+ fallback_config = types.GenerateContentConfig(**config_kwargs)
138
+
139
+ async for chunk in self._do_stream(client, model_id, contents, fallback_config):
140
+ yield chunk
141
+ else:
142
+ logger.error(f"Gemini stream error: {e}")
143
+ yield StreamChunk(type="error", error=str(e))
144
+
145
+ async def _do_stream(self, client, model_id: str, contents, config):
146
+ response_stream = await client.aio.models.generate_content_stream(
147
+ model=model_id,
148
+ contents=contents,
149
+ config=config,
150
+ )
151
+
152
+ pending_tool_calls = []
153
+
154
+ async for chunk in response_stream:
155
+ if not chunk.candidates:
156
+ continue
157
+
158
+ candidate = chunk.candidates[0]
159
+
160
+ if candidate.content and candidate.content.parts:
161
+ for part in candidate.content.parts:
162
+ if hasattr(part, 'thought') and part.thought:
163
+ if part.text:
164
+ yield StreamChunk(type="reasoning", text=part.text)
165
+ elif hasattr(part, 'function_call') and part.function_call:
166
+ fc = part.function_call
167
+ tool_call = ToolCall(
168
+ id=f"call_{fc.name}_{len(pending_tool_calls)}",
169
+ name=fc.name,
170
+ arguments=dict(fc.args) if fc.args else {}
171
+ )
172
+ pending_tool_calls.append(tool_call)
173
+ elif part.text:
174
+ yield StreamChunk(type="text", text=part.text)
175
+
176
+ finish_reason = getattr(candidate, 'finish_reason', None)
177
+ if finish_reason:
178
+ print(f"[Gemini] finish_reason: {finish_reason}, pending_tool_calls: {len(pending_tool_calls)}", flush=True)
179
+ for tc in pending_tool_calls:
180
+ yield StreamChunk(type="tool_call", tool_call=tc)
181
+
182
+ # IMPORTANT: If there are pending tool calls, ALWAYS return "tool_calls"
183
+ # regardless of Gemini's finish_reason (which is often STOP even with tool calls)
184
+ if pending_tool_calls:
185
+ stop_reason = "tool_calls"
186
+ else:
187
+ stop_reason = self._map_stop_reason(finish_reason)
188
+ print(f"[Gemini] Mapped stop_reason: {stop_reason}", flush=True)
189
+
190
+ usage = None
191
+ if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
192
+ usage = {
193
+ "input_tokens": getattr(chunk.usage_metadata, 'prompt_token_count', 0),
194
+ "output_tokens": getattr(chunk.usage_metadata, 'candidates_token_count', 0),
195
+ }
196
+ if hasattr(chunk.usage_metadata, 'thoughts_token_count'):
197
+ usage["thinking_tokens"] = chunk.usage_metadata.thoughts_token_count
198
+
199
+ yield StreamChunk(type="done", usage=usage, stop_reason=stop_reason)
200
+ return
201
+
202
+ yield StreamChunk(type="done", stop_reason="end_turn")
203
+
204
+ def _map_stop_reason(self, gemini_finish_reason) -> str:
205
+ reason_name = str(gemini_finish_reason).lower() if gemini_finish_reason else ""
206
+
207
+ if "stop" in reason_name or "end" in reason_name:
208
+ return "end_turn"
209
+ elif "tool" in reason_name or "function" in reason_name:
210
+ return "tool_calls"
211
+ elif "max" in reason_name or "length" in reason_name:
212
+ return "max_tokens"
213
+ elif "safety" in reason_name:
214
+ return "safety"
215
+ return "end_turn"
src/opencode_api/provider/litellm.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncGenerator
2
+ import json
3
+ import os
4
+
5
+ from .provider import BaseProvider, ModelInfo, Message, StreamChunk, ToolCall
6
+
7
+
8
+ DEFAULT_MODELS = {
9
+ "claude-sonnet-4-20250514": ModelInfo(
10
+ id="claude-sonnet-4-20250514",
11
+ name="Claude Sonnet 4",
12
+ provider_id="litellm",
13
+ context_limit=200000,
14
+ output_limit=64000,
15
+ supports_tools=True,
16
+ supports_streaming=True,
17
+ cost_input=3.0,
18
+ cost_output=15.0,
19
+ ),
20
+ "claude-opus-4-20250514": ModelInfo(
21
+ id="claude-opus-4-20250514",
22
+ name="Claude Opus 4",
23
+ provider_id="litellm",
24
+ context_limit=200000,
25
+ output_limit=32000,
26
+ supports_tools=True,
27
+ supports_streaming=True,
28
+ cost_input=15.0,
29
+ cost_output=75.0,
30
+ ),
31
+ "claude-3-5-haiku-20241022": ModelInfo(
32
+ id="claude-3-5-haiku-20241022",
33
+ name="Claude 3.5 Haiku",
34
+ provider_id="litellm",
35
+ context_limit=200000,
36
+ output_limit=8192,
37
+ supports_tools=True,
38
+ supports_streaming=True,
39
+ cost_input=0.8,
40
+ cost_output=4.0,
41
+ ),
42
+ "gpt-4o": ModelInfo(
43
+ id="gpt-4o",
44
+ name="GPT-4o",
45
+ provider_id="litellm",
46
+ context_limit=128000,
47
+ output_limit=16384,
48
+ supports_tools=True,
49
+ supports_streaming=True,
50
+ cost_input=2.5,
51
+ cost_output=10.0,
52
+ ),
53
+ "gpt-4o-mini": ModelInfo(
54
+ id="gpt-4o-mini",
55
+ name="GPT-4o Mini",
56
+ provider_id="litellm",
57
+ context_limit=128000,
58
+ output_limit=16384,
59
+ supports_tools=True,
60
+ supports_streaming=True,
61
+ cost_input=0.15,
62
+ cost_output=0.6,
63
+ ),
64
+ "o1": ModelInfo(
65
+ id="o1",
66
+ name="O1",
67
+ provider_id="litellm",
68
+ context_limit=200000,
69
+ output_limit=100000,
70
+ supports_tools=True,
71
+ supports_streaming=True,
72
+ cost_input=15.0,
73
+ cost_output=60.0,
74
+ ),
75
+ "gemini/gemini-2.0-flash": ModelInfo(
76
+ id="gemini/gemini-2.0-flash",
77
+ name="Gemini 2.0 Flash",
78
+ provider_id="litellm",
79
+ context_limit=1000000,
80
+ output_limit=8192,
81
+ supports_tools=True,
82
+ supports_streaming=True,
83
+ cost_input=0.075,
84
+ cost_output=0.3,
85
+ ),
86
+ "gemini/gemini-2.5-pro-preview-05-06": ModelInfo(
87
+ id="gemini/gemini-2.5-pro-preview-05-06",
88
+ name="Gemini 2.5 Pro",
89
+ provider_id="litellm",
90
+ context_limit=1000000,
91
+ output_limit=65536,
92
+ supports_tools=True,
93
+ supports_streaming=True,
94
+ cost_input=1.25,
95
+ cost_output=10.0,
96
+ ),
97
+ "groq/llama-3.3-70b-versatile": ModelInfo(
98
+ id="groq/llama-3.3-70b-versatile",
99
+ name="Llama 3.3 70B (Groq)",
100
+ provider_id="litellm",
101
+ context_limit=128000,
102
+ output_limit=32768,
103
+ supports_tools=True,
104
+ supports_streaming=True,
105
+ cost_input=0.59,
106
+ cost_output=0.79,
107
+ ),
108
+ "deepseek/deepseek-chat": ModelInfo(
109
+ id="deepseek/deepseek-chat",
110
+ name="DeepSeek Chat",
111
+ provider_id="litellm",
112
+ context_limit=64000,
113
+ output_limit=8192,
114
+ supports_tools=True,
115
+ supports_streaming=True,
116
+ cost_input=0.14,
117
+ cost_output=0.28,
118
+ ),
119
+ "openrouter/anthropic/claude-sonnet-4": ModelInfo(
120
+ id="openrouter/anthropic/claude-sonnet-4",
121
+ name="Claude Sonnet 4 (OpenRouter)",
122
+ provider_id="litellm",
123
+ context_limit=200000,
124
+ output_limit=64000,
125
+ supports_tools=True,
126
+ supports_streaming=True,
127
+ cost_input=3.0,
128
+ cost_output=15.0,
129
+ ),
130
+ # Z.ai Free Flash Models
131
+ "zai/glm-4.7-flash": ModelInfo(
132
+ id="zai/glm-4.7-flash",
133
+ name="GLM-4.7 Flash (Free)",
134
+ provider_id="litellm",
135
+ context_limit=128000,
136
+ output_limit=8192,
137
+ supports_tools=True,
138
+ supports_streaming=True,
139
+ cost_input=0.0,
140
+ cost_output=0.0,
141
+ ),
142
+ "zai/glm-4.6v-flash": ModelInfo(
143
+ id="zai/glm-4.6v-flash",
144
+ name="GLM-4.6V Flash (Free)",
145
+ provider_id="litellm",
146
+ context_limit=128000,
147
+ output_limit=8192,
148
+ supports_tools=True,
149
+ supports_streaming=True,
150
+ cost_input=0.0,
151
+ cost_output=0.0,
152
+ ),
153
+ "zai/glm-4.5-flash": ModelInfo(
154
+ id="zai/glm-4.5-flash",
155
+ name="GLM-4.5 Flash (Free)",
156
+ provider_id="litellm",
157
+ context_limit=128000,
158
+ output_limit=8192,
159
+ supports_tools=True,
160
+ supports_streaming=True,
161
+ cost_input=0.0,
162
+ cost_output=0.0,
163
+ ),
164
+ }
165
+
166
+
167
+ class LiteLLMProvider(BaseProvider):
168
+
169
+ def __init__(self):
170
+ self._litellm = None
171
+ self._models = dict(DEFAULT_MODELS)
172
+
173
+ @property
174
+ def id(self) -> str:
175
+ return "litellm"
176
+
177
+ @property
178
+ def name(self) -> str:
179
+ return "LiteLLM (Multi-Provider)"
180
+
181
+ @property
182
+ def models(self) -> Dict[str, ModelInfo]:
183
+ return self._models
184
+
185
+ def add_model(self, model: ModelInfo) -> None:
186
+ self._models[model.id] = model
187
+
188
+ def _get_litellm(self):
189
+ if self._litellm is None:
190
+ try:
191
+ import litellm
192
+ litellm.drop_params = True
193
+ self._litellm = litellm
194
+ except ImportError:
195
+ raise ImportError("litellm package is required. Install with: pip install litellm")
196
+ return self._litellm
197
+
198
+ async def stream(
199
+ self,
200
+ model_id: str,
201
+ messages: List[Message],
202
+ tools: Optional[List[Dict[str, Any]]] = None,
203
+ system: Optional[str] = None,
204
+ temperature: Optional[float] = None,
205
+ max_tokens: Optional[int] = None,
206
+ ) -> AsyncGenerator[StreamChunk, None]:
207
+ litellm = self._get_litellm()
208
+
209
+ litellm_messages = []
210
+
211
+ if system:
212
+ litellm_messages.append({"role": "system", "content": system})
213
+
214
+ for msg in messages:
215
+ content = msg.content
216
+ if isinstance(content, str):
217
+ litellm_messages.append({"role": msg.role, "content": content})
218
+ else:
219
+ litellm_messages.append({
220
+ "role": msg.role,
221
+ "content": [{"type": c.type, "text": c.text} for c in content if c.text]
222
+ })
223
+
224
+ # Z.ai 모델 처리: OpenAI-compatible API 사용
225
+ actual_model = model_id
226
+ if model_id.startswith("zai/"):
227
+ # zai/glm-4.7-flash -> openai/glm-4.7-flash with custom api_base
228
+ actual_model = "openai/" + model_id[4:]
229
+
230
+ kwargs: Dict[str, Any] = {
231
+ "model": actual_model,
232
+ "messages": litellm_messages,
233
+ "stream": True,
234
+ }
235
+
236
+ # Z.ai 전용 설정
237
+ if model_id.startswith("zai/"):
238
+ kwargs["api_base"] = os.environ.get("ZAI_API_BASE", "https://api.z.ai/api/paas/v4")
239
+ kwargs["api_key"] = os.environ.get("ZAI_API_KEY")
240
+
241
+ if temperature is not None:
242
+ kwargs["temperature"] = temperature
243
+
244
+ if max_tokens is not None:
245
+ kwargs["max_tokens"] = max_tokens
246
+ else:
247
+ kwargs["max_tokens"] = 8192
248
+
249
+ if tools:
250
+ kwargs["tools"] = [
251
+ {
252
+ "type": "function",
253
+ "function": {
254
+ "name": t["name"],
255
+ "description": t.get("description", ""),
256
+ "parameters": t.get("parameters", t.get("input_schema", {}))
257
+ }
258
+ }
259
+ for t in tools
260
+ ]
261
+
262
+ current_tool_calls: Dict[int, Dict[str, Any]] = {}
263
+
264
+ try:
265
+ response = await litellm.acompletion(**kwargs)
266
+
267
+ async for chunk in response:
268
+ if hasattr(chunk, 'choices') and chunk.choices:
269
+ choice = chunk.choices[0]
270
+ delta = getattr(choice, 'delta', None)
271
+
272
+ if delta:
273
+ if hasattr(delta, 'content') and delta.content:
274
+ yield StreamChunk(type="text", text=delta.content)
275
+
276
+ if hasattr(delta, 'tool_calls') and delta.tool_calls:
277
+ for tc in delta.tool_calls:
278
+ idx = tc.index if hasattr(tc, 'index') else 0
279
+
280
+ if idx not in current_tool_calls:
281
+ current_tool_calls[idx] = {
282
+ "id": tc.id if hasattr(tc, 'id') and tc.id else f"call_{idx}",
283
+ "name": "",
284
+ "arguments_json": ""
285
+ }
286
+
287
+ if hasattr(tc, 'function'):
288
+ if hasattr(tc.function, 'name') and tc.function.name:
289
+ current_tool_calls[idx]["name"] = tc.function.name
290
+ if hasattr(tc.function, 'arguments') and tc.function.arguments:
291
+ current_tool_calls[idx]["arguments_json"] += tc.function.arguments
292
+
293
+ finish_reason = getattr(choice, 'finish_reason', None)
294
+ if finish_reason:
295
+ for idx, tc_data in current_tool_calls.items():
296
+ if tc_data["name"]:
297
+ try:
298
+ args = json.loads(tc_data["arguments_json"]) if tc_data["arguments_json"] else {}
299
+ except json.JSONDecodeError:
300
+ args = {}
301
+
302
+ yield StreamChunk(
303
+ type="tool_call",
304
+ tool_call=ToolCall(
305
+ id=tc_data["id"],
306
+ name=tc_data["name"],
307
+ arguments=args
308
+ )
309
+ )
310
+
311
+ usage = None
312
+ if hasattr(chunk, 'usage') and chunk.usage:
313
+ usage = {
314
+ "input_tokens": getattr(chunk.usage, 'prompt_tokens', 0),
315
+ "output_tokens": getattr(chunk.usage, 'completion_tokens', 0),
316
+ }
317
+
318
+ stop_reason = self._map_stop_reason(finish_reason)
319
+ yield StreamChunk(type="done", usage=usage, stop_reason=stop_reason)
320
+
321
+ except Exception as e:
322
+ yield StreamChunk(type="error", error=str(e))
323
+
324
+ async def complete(
325
+ self,
326
+ model_id: str,
327
+ prompt: str,
328
+ max_tokens: int = 100,
329
+ ) -> str:
330
+ """단일 완료 요청 (스트리밍 없음)"""
331
+ litellm = self._get_litellm()
332
+
333
+ actual_model = model_id
334
+ kwargs: Dict[str, Any] = {
335
+ "model": actual_model,
336
+ "messages": [{"role": "user", "content": prompt}],
337
+ "max_tokens": max_tokens,
338
+ }
339
+
340
+ # Z.ai 모델 처리
341
+ if model_id.startswith("zai/"):
342
+ actual_model = "openai/" + model_id[4:]
343
+ kwargs["model"] = actual_model
344
+ kwargs["api_base"] = os.environ.get("ZAI_API_BASE", "https://api.z.ai/api/paas/v4")
345
+ kwargs["api_key"] = os.environ.get("ZAI_API_KEY")
346
+
347
+ response = await litellm.acompletion(**kwargs)
348
+ return response.choices[0].message.content or ""
349
+
350
+ def _map_stop_reason(self, finish_reason: Optional[str]) -> str:
351
+ if not finish_reason:
352
+ return "end_turn"
353
+
354
+ mapping = {
355
+ "stop": "end_turn",
356
+ "end_turn": "end_turn",
357
+ "tool_calls": "tool_calls",
358
+ "function_call": "tool_calls",
359
+ "length": "max_tokens",
360
+ "max_tokens": "max_tokens",
361
+ "content_filter": "content_filter",
362
+ }
363
+ return mapping.get(finish_reason, "end_turn")
src/opencode_api/provider/openai.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncGenerator
2
+ import os
3
+ import json
4
+
5
+ from .provider import BaseProvider, ModelInfo, Message, StreamChunk, ToolCall
6
+
7
+
8
+ class OpenAIProvider(BaseProvider):
9
+
10
+ def __init__(self, api_key: Optional[str] = None):
11
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
12
+ self._client = None
13
+
14
+ @property
15
+ def id(self) -> str:
16
+ return "openai"
17
+
18
+ @property
19
+ def name(self) -> str:
20
+ return "OpenAI"
21
+
22
+ @property
23
+ def models(self) -> Dict[str, ModelInfo]:
24
+ return {
25
+ "gpt-4o": ModelInfo(
26
+ id="gpt-4o",
27
+ name="GPT-4o",
28
+ provider_id="openai",
29
+ context_limit=128000,
30
+ output_limit=16384,
31
+ supports_tools=True,
32
+ supports_streaming=True,
33
+ cost_input=2.5,
34
+ cost_output=10.0,
35
+ ),
36
+ "gpt-4o-mini": ModelInfo(
37
+ id="gpt-4o-mini",
38
+ name="GPT-4o Mini",
39
+ provider_id="openai",
40
+ context_limit=128000,
41
+ output_limit=16384,
42
+ supports_tools=True,
43
+ supports_streaming=True,
44
+ cost_input=0.15,
45
+ cost_output=0.6,
46
+ ),
47
+ "o1": ModelInfo(
48
+ id="o1",
49
+ name="o1",
50
+ provider_id="openai",
51
+ context_limit=200000,
52
+ output_limit=100000,
53
+ supports_tools=True,
54
+ supports_streaming=True,
55
+ cost_input=15.0,
56
+ cost_output=60.0,
57
+ ),
58
+ }
59
+
60
+ def _get_client(self):
61
+ if self._client is None:
62
+ try:
63
+ from openai import AsyncOpenAI
64
+ self._client = AsyncOpenAI(api_key=self._api_key)
65
+ except ImportError:
66
+ raise ImportError("openai package is required. Install with: pip install openai")
67
+ return self._client
68
+
69
+ async def stream(
70
+ self,
71
+ model_id: str,
72
+ messages: List[Message],
73
+ tools: Optional[List[Dict[str, Any]]] = None,
74
+ system: Optional[str] = None,
75
+ temperature: Optional[float] = None,
76
+ max_tokens: Optional[int] = None,
77
+ ) -> AsyncGenerator[StreamChunk, None]:
78
+ client = self._get_client()
79
+
80
+ openai_messages = []
81
+
82
+ if system:
83
+ openai_messages.append({"role": "system", "content": system})
84
+
85
+ for msg in messages:
86
+ content = msg.content
87
+ if isinstance(content, str):
88
+ openai_messages.append({"role": msg.role, "content": content})
89
+ else:
90
+ openai_messages.append({
91
+ "role": msg.role,
92
+ "content": [{"type": c.type, "text": c.text} for c in content if c.text]
93
+ })
94
+
95
+ kwargs: Dict[str, Any] = {
96
+ "model": model_id,
97
+ "messages": openai_messages,
98
+ "stream": True,
99
+ }
100
+
101
+ if max_tokens:
102
+ kwargs["max_tokens"] = max_tokens
103
+
104
+ if temperature is not None:
105
+ kwargs["temperature"] = temperature
106
+
107
+ if tools:
108
+ kwargs["tools"] = [
109
+ {
110
+ "type": "function",
111
+ "function": {
112
+ "name": t["name"],
113
+ "description": t.get("description", ""),
114
+ "parameters": t.get("parameters", t.get("input_schema", {}))
115
+ }
116
+ }
117
+ for t in tools
118
+ ]
119
+
120
+ tool_calls: Dict[int, Dict[str, Any]] = {}
121
+ usage_data = None
122
+ finish_reason = None
123
+
124
+ async for chunk in await client.chat.completions.create(**kwargs):
125
+ if chunk.choices and chunk.choices[0].delta:
126
+ delta = chunk.choices[0].delta
127
+
128
+ if delta.content:
129
+ yield StreamChunk(type="text", text=delta.content)
130
+
131
+ if delta.tool_calls:
132
+ for tc in delta.tool_calls:
133
+ idx = tc.index
134
+ if idx not in tool_calls:
135
+ tool_calls[idx] = {
136
+ "id": tc.id or "",
137
+ "name": tc.function.name if tc.function else "",
138
+ "arguments": ""
139
+ }
140
+
141
+ if tc.id:
142
+ tool_calls[idx]["id"] = tc.id
143
+ if tc.function:
144
+ if tc.function.name:
145
+ tool_calls[idx]["name"] = tc.function.name
146
+ if tc.function.arguments:
147
+ tool_calls[idx]["arguments"] += tc.function.arguments
148
+
149
+ if chunk.choices and chunk.choices[0].finish_reason:
150
+ finish_reason = chunk.choices[0].finish_reason
151
+
152
+ if chunk.usage:
153
+ usage_data = {
154
+ "input_tokens": chunk.usage.prompt_tokens,
155
+ "output_tokens": chunk.usage.completion_tokens,
156
+ }
157
+
158
+ for tc_data in tool_calls.values():
159
+ try:
160
+ args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {}
161
+ except json.JSONDecodeError:
162
+ args = {}
163
+ yield StreamChunk(
164
+ type="tool_call",
165
+ tool_call=ToolCall(
166
+ id=tc_data["id"],
167
+ name=tc_data["name"],
168
+ arguments=args
169
+ )
170
+ )
171
+
172
+ stop_reason = self._map_stop_reason(finish_reason)
173
+ yield StreamChunk(type="done", usage=usage_data, stop_reason=stop_reason)
174
+
175
+ def _map_stop_reason(self, openai_finish_reason: Optional[str]) -> str:
176
+ mapping = {
177
+ "stop": "end_turn",
178
+ "tool_calls": "tool_calls",
179
+ "length": "max_tokens",
180
+ "content_filter": "end_turn",
181
+ }
182
+ return mapping.get(openai_finish_reason or "", "end_turn")
src/opencode_api/provider/provider.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional, AsyncIterator, AsyncGenerator, Protocol, runtime_checkable
2
+ from pydantic import BaseModel, Field
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class ModelInfo(BaseModel):
7
+ id: str
8
+ name: str
9
+ provider_id: str
10
+ context_limit: int = 128000
11
+ output_limit: int = 8192
12
+ supports_tools: bool = True
13
+ supports_streaming: bool = True
14
+ cost_input: float = 0.0 # per 1M tokens
15
+ cost_output: float = 0.0 # per 1M tokens
16
+
17
+
18
+ class ProviderInfo(BaseModel):
19
+ id: str
20
+ name: str
21
+ models: Dict[str, ModelInfo] = Field(default_factory=dict)
22
+
23
+
24
+ class MessageContent(BaseModel):
25
+ type: str = "text"
26
+ text: Optional[str] = None
27
+
28
+
29
+ class Message(BaseModel):
30
+ role: str # "user", "assistant", "system"
31
+ content: str | List[MessageContent]
32
+
33
+
34
+ class ToolCall(BaseModel):
35
+ id: str
36
+ name: str
37
+ arguments: Dict[str, Any]
38
+
39
+
40
+ class ToolResult(BaseModel):
41
+ tool_call_id: str
42
+ output: str
43
+
44
+
45
+ class StreamChunk(BaseModel):
46
+ type: str # "text", "reasoning", "tool_call", "tool_result", "done", "error"
47
+ text: Optional[str] = None
48
+ tool_call: Optional[ToolCall] = None
49
+ error: Optional[str] = None
50
+ usage: Optional[Dict[str, int]] = None
51
+ stop_reason: Optional[str] = None # "end_turn", "tool_calls", "max_tokens", etc.
52
+
53
+
54
+ @runtime_checkable
55
+ class Provider(Protocol):
56
+
57
+ @property
58
+ def id(self) -> str: ...
59
+
60
+ @property
61
+ def name(self) -> str: ...
62
+
63
+ @property
64
+ def models(self) -> Dict[str, ModelInfo]: ...
65
+
66
+ def stream(
67
+ self,
68
+ model_id: str,
69
+ messages: List[Message],
70
+ tools: Optional[List[Dict[str, Any]]] = None,
71
+ system: Optional[str] = None,
72
+ temperature: Optional[float] = None,
73
+ max_tokens: Optional[int] = None,
74
+ ) -> AsyncGenerator[StreamChunk, None]: ...
75
+
76
+
77
+ class BaseProvider(ABC):
78
+
79
+ @property
80
+ @abstractmethod
81
+ def id(self) -> str:
82
+ pass
83
+
84
+ @property
85
+ @abstractmethod
86
+ def name(self) -> str:
87
+ pass
88
+
89
+ @property
90
+ @abstractmethod
91
+ def models(self) -> Dict[str, ModelInfo]:
92
+ pass
93
+
94
+ @abstractmethod
95
+ def stream(
96
+ self,
97
+ model_id: str,
98
+ messages: List[Message],
99
+ tools: Optional[List[Dict[str, Any]]] = None,
100
+ system: Optional[str] = None,
101
+ temperature: Optional[float] = None,
102
+ max_tokens: Optional[int] = None,
103
+ ) -> AsyncGenerator[StreamChunk, None]:
104
+ pass
105
+
106
+ def get_info(self) -> ProviderInfo:
107
+ return ProviderInfo(
108
+ id=self.id,
109
+ name=self.name,
110
+ models=self.models
111
+ )
112
+
113
+
114
+ _providers: Dict[str, BaseProvider] = {}
115
+
116
+
117
+ def register_provider(provider: BaseProvider) -> None:
118
+ _providers[provider.id] = provider
119
+
120
+
121
+ def get_provider(provider_id: str) -> Optional[BaseProvider]:
122
+ return _providers.get(provider_id)
123
+
124
+
125
+ def list_providers() -> List[ProviderInfo]:
126
+ return [p.get_info() for p in _providers.values()]
127
+
128
+
129
+ def get_model(provider_id: str, model_id: str) -> Optional[ModelInfo]:
130
+ provider = get_provider(provider_id)
131
+ if provider:
132
+ return provider.models.get(model_id)
133
+ return None
src/opencode_api/routes/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from .session import router as session_router
2
+ from .provider import router as provider_router
3
+ from .event import router as event_router
4
+ from .question import router as question_router
5
+ from .agent import router as agent_router
6
+
7
+ __all__ = ["session_router", "provider_router", "event_router", "question_router", "agent_router"]
src/opencode_api/routes/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (399 Bytes). View file
 
src/opencode_api/routes/__pycache__/agent.cpython-312.pyc ADDED
Binary file (2.7 kB). View file
 
src/opencode_api/routes/__pycache__/event.cpython-312.pyc ADDED
Binary file (2.47 kB). View file
 
src/opencode_api/routes/__pycache__/provider.cpython-312.pyc ADDED
Binary file (4.96 kB). View file