| """FastAPI app wiring Copilot onto the OpenAI Chat Completions API.""" |
|
|
| import os |
| import threading |
| import time |
|
|
| from typing import Optional |
|
|
| from fastapi import FastAPI |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse |
|
|
| from copilot import CopilotClient |
| from copilot.auth import SESSION_ENV_VAR, _load_session_from_env |
|
|
| from .config import MODEL_NAME, RATE_LIMIT_BURST, RATE_LIMIT_RPM |
| from .openai_format import ( |
| completion_response, |
| new_id, |
| sse_event, |
| stream_chunk, |
| ) |
| from .prompt import messages_to_prompt |
| from .ratelimit import TokenBucket |
| from .schemas import ChatCompletionRequest |
|
|
| app = FastAPI(title="Copilot OpenAI-compatible API", version="1.0.0") |
|
|
| |
| |
| |
| _client: Optional[CopilotClient] = None |
|
|
|
|
| def _get_client() -> CopilotClient: |
| global _client |
| if _client is not None: |
| return _client |
| |
| injected = getattr(app.state, "copilot_client", None) |
| if injected is not None: |
| _client = injected |
| return _client |
| _client = CopilotClient() |
| return _client |
|
|
| |
| |
| _rate_limiter = TokenBucket(RATE_LIMIT_RPM, RATE_LIMIT_BURST) |
|
|
|
|
| def _rate_limited_response(): |
| """Spend a token; return an OpenAI-shaped 429 if none left, else ``None``.""" |
| allowed, wait = _rate_limiter.try_acquire() |
| if allowed: |
| return None |
| secs = max(1, round(wait)) |
| return JSONResponse( |
| status_code=429, |
| headers={"Retry-After": str(secs)}, |
| content={"error": { |
| "message": ( |
| f"Rate limit exceeded (>{RATE_LIMIT_RPM:g} req/min). " |
| f"Retry in {secs}s." |
| ), |
| "type": "rate_limit_error", |
| "code": "rate_limit_exceeded", |
| }}, |
| ) |
|
|
| |
| |
| |
| |
| |
| _upstream_lock = threading.Lock() |
|
|
|
|
| def _stream(prompt: str, model: str, conversation_id=None): |
| """Yield OpenAI ``chat.completion.chunk`` SSE events for ``prompt``. |
| |
| ``conversation_id`` continues an existing Copilot thread; ``None`` starts a |
| fresh one (its id is emitted on the final chunk). |
| """ |
| cid = new_id() |
| created = int(time.time()) |
| try: |
| with _upstream_lock: |
| yield sse_event(stream_chunk(cid, created, model, {"role": "assistant"})) |
| stream = _get_client().stream(prompt, conversation_id=conversation_id) |
| for piece in stream: |
| if isinstance(piece, str) and piece: |
| yield sse_event(stream_chunk(cid, created, model, {"content": piece})) |
| |
| |
| yield sse_event( |
| stream_chunk( |
| cid, created, model, {}, finish="stop", |
| conversation_id=stream.conversation_id, |
| ) |
| ) |
| except Exception as exc: |
| yield sse_event( |
| stream_chunk(cid, created, model, {"content": f"\n[error: {exc}]"}, finish="error") |
| ) |
| yield "data: [DONE]\n\n" |
|
|
|
|
| @app.get("/v1/models") |
| def list_models(): |
| return { |
| "object": "list", |
| "data": [ |
| {"id": MODEL_NAME, "object": "model", "created": 0, "owned_by": "microsoft"} |
| ], |
| } |
|
|
|
|
| @app.post("/v1/chat/completions") |
| def chat_completions(req: ChatCompletionRequest): |
| prompt = messages_to_prompt(req.messages) |
| if not prompt.strip(): |
| return JSONResponse( |
| status_code=400, |
| content={"error": {"message": "no text content in messages", "type": "invalid_request_error"}}, |
| ) |
| model = req.model or MODEL_NAME |
|
|
| |
| |
| limited = _rate_limited_response() |
| if limited is not None: |
| return limited |
|
|
| if req.stream: |
| return StreamingResponse( |
| _stream(prompt, model, req.conversation_id), media_type="text/event-stream" |
| ) |
|
|
| try: |
| with _upstream_lock: |
| reply = _get_client().chat(prompt, conversation_id=req.conversation_id) |
| except Exception as exc: |
| return JSONResponse( |
| status_code=502, |
| content={"error": {"message": str(exc), "type": "upstream_error"}}, |
| ) |
| return completion_response(reply.text, model, reply.conversation_id) |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def root(): |
| """Status landing page — shows auth mode and quick-start instructions.""" |
| env_session = _load_session_from_env() |
| anonymous = os.environ.get("ANONYMOUS", "").lower() in ("1", "true", "yes") |
|
|
| if env_session is not None: |
| auth_mode = "Signed-in (via COPILOT_SESSION)" |
| auth_color = "#22c55e" |
| auth_detail = "Session loaded from environment variable / HF Secret." |
| elif anonymous: |
| auth_mode = "Anonymous" |
| auth_color = "#eab308" |
| auth_detail = ( |
| "Running without sign-in. May be geo-blocked in some regions. " |
| f"Set <code>{SESSION_ENV_VAR}</code> to use your signed-in account." |
| ) |
| else: |
| auth_mode = "Signed-in (local session)" |
| auth_color = "#22c55e" |
| auth_detail = "Session from local browser login." |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| <title>Copilot OpenAI-compatible API</title> |
| <style> |
| body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; |
| max-width: 720px; margin: 40px auto; padding: 0 20px; color: #1f2937; line-height: 1.6; }} |
| h1 {{ border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }} |
| code {{ background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 90%; }} |
| pre {{ background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; }} |
| .badge {{ display: inline-block; padding: 4px 12px; border-radius: 9999px; |
| font-weight: 600; font-size: 14px; color: #fff; background: {auth_color}; }} |
| .endpoint {{ margin: 8px 0; padding: 8px 12px; background: #f9fafb; border-radius: 6px; }} |
| .method {{ font-weight: 700; color: #3b82f6; margin-right: 8px; }} |
| </style> |
| </head> |
| <body> |
| <h1>Copilot OpenAI-compatible API</h1> |
| <p>Auth mode: <span class="badge">{auth_mode}</span></p> |
| <p style="color:#6b7280;font-size:14px;">{auth_detail}</p> |
| |
| <h2>Endpoints</h2> |
| <div class="endpoint"><span class="method">POST</span><code>/v1/chat/completions</code> — Chat completion (supports streaming)</div> |
| <div class="endpoint"><span class="method">GET</span><code>/v1/models</code> — List available models</div> |
| |
| <h2>Quick start</h2> |
| <pre>from openai import OpenAI |
| |
| client = OpenAI( |
| base_url="YOUR_SPACE_URL/v1", |
| api_key="unused", |
| ) |
| |
| resp = client.chat.completions.create( |
| model="copilot", |
| messages=[{{"role": "user", "content": "Hello!"}}], |
| ) |
| print(resp.choices[0].message.content)</pre> |
| |
| <h2>Sign in with your account</h2> |
| <p>To use your signed-in Microsoft Copilot account on this Space:</p> |
| <ol> |
| <li>Run this project locally and sign in:<br><code>python -m copilot login</code></li> |
| <li>Copy the contents of <code>session/token.json</code></li> |
| <li>Go to your Space <b>Settings → Variables and secrets</b></li> |
| <li>Add a new Secret named <code>{SESSION_ENV_VAR}</code> and paste the JSON</li> |
| <li>If <code>ANONYMOUS=true</code> was set, remove it (or set <code>ANONYMOUS=false</code>)</li> |
| <li>Restart the Space</li> |
| </ol> |
| <p style="color:#6b7280;font-size:13px;">Note: Microsoft access tokens expire after ~60-90 minutes. When the token expires you will need to re-login locally and update the Secret. For long-running use, consider running the project on a host where you can keep a persistent browser profile.</p> |
| </body> |
| </html>""" |
|
|