Stream OpenCode Go with FastAPI
Build a FastAPI proxy that streams OpenCode Go chat responses to a browser.
Introduction
30 Second Summary
Waiting for an online answer is frustrating when the screen stays empty. Seeing the response appear as it is written makes the wait feel immediate.
In this project, you will build a FastAPI proxy that keeps your hosted OpenCode Go API key on the backend. Your browser chat will render each answer progressively from a Server-Sent Events stream.
What You'll Build
Your local chat page will turn one prompt into an OpenCode Go answer that grows inside the assistant bubble in real time.
By the end of this project, you'll have:
- A browser chat interface where every prompt becomes a user bubble. Each reply fills an assistant bubble.
- Protected provider access that forwards each message through FastAPI without exposing your API key in browser code.
- An incremental response that grows the assistant bubble chunk by chunk. Request failures appear in the status line.
- Secret Mission: Add a Stop button that cancels an in-progress answer. The page remains ready for another prompt.
Are there any prerequisites?
You need macOS with Python 3.10+, Google Chrome, plus a code editor. You also need an active $10/month OpenCode Go subscription with an API key.
Before We Start
This is your moment to commit to a local streaming proxy where the browser displays assistant text progressively. FastAPI keeps OPENCODE_GO_API_KEY on the backend because browser JavaScript and network requests can expose a secret key to users.
Set Up the Python Project
Your proxy needs a predictable Python runtime before it can protect the API key or relay a stream. Global packages can clash with dependencies from other projects.
A virtual environment gives this project its own packages. Pinned dependencies make every later step use the same FastAPI runtime.
In this step, get ready to:
- Verify the required Python runtime.
- Prepare the isolated project structure with pinned dependencies.
- Launch a minimal FastAPI development server.
Verify Python and create the project structure
FastAPI 0.141.1 requires Python 3.10 or newer. Checking the interpreter first prevents confusing installation errors later.
- Press Cmd+Space to open the macOS search bar.
- Type Terminal.
- Press Enter to open Terminal.
- Check your installed Python version by running this command:
python --version
What does this check prove?
The command reports the Python interpreter available to your shell. A version of 3.10 or newer can run the pinned FastAPI release.
✔️ I see version 3.10 or higher
Your Python runtime meets the project requirement. Continue below to create the project structure.
ⓧ I see an older version
The installed interpreter is below the version required by FastAPI 0.141.1. Install a current supported Python release before creating the environment.
- Visit the official Python releases for macOS page.
- Choose a supported release that is version 3.10 or newer.
- Click Download macOS installer for that release.
- Run the downloaded installer package.
- Open a new Terminal window after the installation finishes.
- Verify the updated Python version by running this command:
python --version
Why check again?
The second check confirms that your shell can find the newly installed interpreter. Continue when the reported version is 3.10 or newer.
ⓧ Command not found
Your shell cannot find a Python interpreter. The official macOS installer adds a supported Python release to your machine.
- Visit the official Python releases for macOS page.
- Choose a supported release that is version 3.10 or newer.
- Click Download macOS installer for that release.
- Run the downloaded installer package.
- Open a new Terminal window after the installation finishes.
- Verify the installation by running this command:
python --version
What confirms the installation?
Terminal should now report a Python version of 3.10 or newer. That output confirms the interpreter is available to the project.
The project folder keeps the backend files separate from the browser assets. The static/ directory is reserved for the chat interface you build in the next step.
- Create the project structure on your Desktop by running these commands:
cd ~/Desktop
mkdir opencode-stream-proxy
cd opencode-stream-proxy
mkdir static
touch main.py requirements.txt .gitignore
What do these commands create?
- The first command moves Terminal to your Desktop.
- The next two commands create opencode-stream-proxy and make it the current folder.
- The final commands create the static/ directory plus the three project files.
- Confirm the visible project items by running this command:
ls
What should I see?
Terminal should list main.py, requirements.txt, and static. The .gitignore file stays hidden from a standard directory listing because its name starts with a period.
Good start. Your backend files now have a dedicated home beside the directory that will hold the browser interface.
Create the environment and install dependencies
The dependency file records the exact packages required by the proxy. The virtual environment installs those packages inside opencode-stream-proxy without changing other Python projects.
- Use your code editor's folder-opening command to load opencode-stream-proxy from your Desktop.
- Select requirements.txt in the file explorer.
- Add the pinned dependencies by replacing the file contents with this code:
fastapi[standard]==0.141.1
httpx==0.28.1
pydantic==2.13.5
What do these dependencies provide?
- FastAPI 0.141.1 supplies the web application plus its development command.
- HTTPX 0.28.1 will send the hosted model request and read its response stream.
- Pydantic 2.13.5 will validate the browser's request body.
- Save requirements.txt.
- Return to the Terminal window inside opencode-stream-proxy.
- Create and activate the isolated environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate
What does activation change?
The first command creates the environment in .venv/. The second command updates the current shell so Python packages install into that environment.
You should see the environment name at the start of your Terminal prompt. That small prompt change confirms the project is isolated.
- Install the pinned dependencies into the active environment by running this command:
python3 -m pip install -r requirements.txt
What does this installation use?
The installer reads each pin from requirements.txt and places the matching package in .venv/. This keeps the runtime reproducible across later steps.
Before you check, which Python version and three package versions do you expect Terminal to report?
- Verify the active interpreter and installed packages by running these commands:
python --version
python -m pip show fastapi httpx pydantic
What should the package check show?
Terminal should report Python 3.10 or newer. The package details should show FastAPI 0.141.1, HTTPX 0.28.1, and Pydantic 2.13.5.
That is the dependency layer locked down. Your proxy now has the exact runtime it needs.
Missing a package or seeing the wrong version?
- Check that the environment name still appears at the start of your Terminal prompt.
- Confirm that requirements.txt contains all three pins exactly as shown above.
- Run the installation command again after correcting the file.
- Help me diagnose my Python virtual environment and pinned package installation.
The environment creates generated files that do not belong in source control. Git ignore rules keep those files outside future commits.
- Return to your code editor.
- Select .gitignore in the file explorer.
- Add the generated-file rules by replacing the file contents with this code:
.venv/
__pycache__/
*.pyc
What do these rules exclude?
- The .venv/ rule excludes the disposable project environment.
- The __pycache__/ rule excludes Python's generated cache directories.
- The *.pyc rule excludes compiled Python cache files.
- Save .gitignore.
- Confirm that the file contains exactly three ignore patterns.
Launch the minimal FastAPI app
A minimal application is enough to prove that the installation works. FastAPI looks for an object named app in main.py when the development server starts.
- Select main.py in your code editor's file explorer.
- Create the FastAPI application by replacing the file contents with this code:
from fastapi import FastAPI
app = FastAPI()
What does this code do?
- The import makes the FastAPI application class available in main.py.
- The app object becomes the application discovered by the development command.
- Save main.py.
- Return to the active environment in Terminal.
Before you start the server, do you expect FastAPI to find the app object automatically?
- Start the development server by running this command:
fastapi dev
What does the development command do?
The command detects app in main.py and starts a local development server. It keeps this Terminal window occupied while the server is running.
Press Control-C whenever you need to stop it. Leave it running for the browser check below.
Terminal should show that the server is available at http://127.0.0.1:8000. Your FastAPI runtime is now accepting local connections.
- Press Cmd+Space to open the macOS search bar.
- Type Google Chrome.
- Press Enter to open Google Chrome.
- Enter http://127.0.0.1:8000 in the address bar.
Chrome should show FastAPI's default response for a route that has not been added yet. That response proves the browser can reach your local server.
Cannot reach the local server?
- Check that the Terminal window still shows the running development server.
- Confirm that Terminal is inside the opencode-stream-proxy folder.
- Confirm that main.py defines an object named app.
- Help me debug why my minimal FastAPI app does not load locally.
✔️ Awesome, I've got everything!
Your environment is active. Double check that all three files are saved before continuing.
ⓧ I'd like to double check the full code
Compare your requirements.txt file with this complete version.
fastapi[standard]==0.141.1
httpx==0.28.1
pydantic==2.13.5
Compare your .gitignore file with this complete version.
.venv/
__pycache__/
*.pyc
Compare your main.py file with this complete version.
from fastapi import FastAPI
app = FastAPI()
Confirm that the empty static/ directory still exists beside these files. It is ready for the browser assets.
Your isolated Python project is running locally with every dependency pinned. Next up, you will turn that empty browser-assets directory into the chat interface.
Build the Browser Chat Shell
Your FastAPI development server is running. The root URL still lacks an interface for sending prompts or reading replies.
A local chat shell gives you an early visible result before authentication enters the project. It also creates the browser surface that later displays streamed text.
In this step, get ready to:
- Build a responsive chat page with a transcript area and prompt controls.
- Serve the chat page from the FastAPI root route.
- Submit a local prompt and display message bubbles in the browser.
Build the chat page
HTML gives the page its controls. CSS turns those controls into a responsive chat layout.
- Use your code editor's file sidebar to create static/index.html inside static/.
- Paste this first page chunk into static/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenCode Go Stream</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #08111f; color: #e5eefb; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #17315b 0, #08111f 45%); }
.app { width: min(760px, 100%); min-height: 620px; display: grid; grid-template-rows: auto 1fr auto auto; gap: 16px; padding: 24px; border: 1px solid #29466f; border-radius: 20px; background: rgba(8, 17, 31, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
header { display: flex; align-items: center; gap: 10px; }
h1 { margin: 0; font-size: 1.25rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: #3ddc97; box-shadow: 0 0 14px #3ddc97; }
</style>
</head>
<body>
<main class="app">
<header><span class="dot" aria-hidden="true"></span><h1>OpenCode Go Streaming Chat</h1></header>
<section id="messages" aria-live="polite"><div class="message assistant">Ask me to explain a programming concept.</div></section>
<form id="chat-form">
<label for="prompt" hidden>Message</label>
<textarea id="prompt" placeholder="Ask OpenCode Go..." required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
<p id="status" role="status">Ready</p>
</main>
</body>
</html>
What does this page create?
- The app container holds the complete chat panel.
- The messages section provides a live transcript area for conversation bubbles.
- The chat-form form groups the prompt box with its Send button.
- The status paragraph gives the browser a dedicated place for request updates.
- Save static/index.html.
- Confirm index.html appears beneath static/ in your editor's file sidebar.
Can't find the new page?
- Make sure index.html sits directly inside the existing static/ folder.
- Check that the filename ends in .html.
- Confirm your editor saved the file inside opencode-stream-proxy.
- Help me locate my browser file.
The remaining styles control message bubbles and form fields. They also adapt the page for narrow screens.
- Find the .dot style rule in static/index.html.
- Paste these styles below that rule before </style>:
#messages { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 14px; background: #0d1b2e; }
.message { max-width: 82%; padding: 12px 14px; border-radius: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.user { align-self: flex-end; background: #2f6fed; color: white; }
.assistant { align-self: flex-start; background: #172a45; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 54px; resize: vertical; padding: 14px; border: 1px solid #35547c; border-radius: 12px; background: #0d1b2e; color: inherit; font: inherit; }
button { padding: 0 20px; border: 0; border-radius: 12px; background: #3ddc97; color: #06120d; font-weight: 750; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.55; }
#status { min-height: 1.25rem; margin: 0; color: #9bb2cf; font-size: 0.9rem; }
@media (max-width: 600px) { body { padding: 0; } .app { min-height: 100vh; border: 0; border-radius: 0; } form { grid-template-columns: 1fr; } button { min-height: 48px; } }
How do these styles shape the chat?
- The messages panel stacks bubbles vertically inside a scrollable transcript.
- The user class moves learner messages to the right.
- The assistant class keeps model messages on the left.
- The media query gives the form a single-column layout on smaller screens.
- Save static/index.html.
- Scroll to the end of the style block.
You should see the responsive @media (max-width: 600px) rule directly before </style>.
Styles in the wrong place?
- Keep every new selector between <style> and </style>.
- Check that each selector still has both curly braces.
- Compare the indentation with the existing .dot rule.
- Help me fix the chat page styles.
Serve the page from FastAPI
The browser needs an HTTP route that returns the new file. FileResponse sends static/index.html when the browser requests /.
- Select main.py from your code editor's file sidebar.
- Replace its current contents with this route code:
from fastapi import FastAPI
from fastapi.responses import FileResponse
app = FastAPI()
@app.get("/")
async def index():
return FileResponse("static/index.html")
What does this route do?
- The FileResponse import gives FastAPI a response type for serving a named file.
- The @app.get("/") decorator connects browser requests for the root path to index().
- The return statement sends the chat page from static/index.html.
- Save main.py.
- Return to the server terminal from the previous step.
Your FastAPI development server remains available at its existing local address.
Before you load the page, which chat elements do you expect the new root route to reveal?
- Return to Chrome from the earlier server check.
- Select the browser address bar.
- Enter http://127.0.0.1:8000.
- Press Enter.
You'll see the OpenCode Go Streaming Chat title above a dark transcript panel. The first assistant message invites you to ask about a programming concept.
The prompt box sits beside the Send button. The status line reads Ready.
Your first visible win is on screen. FastAPI now serves a complete browser interface from the project.
Chat page not loading?
- Confirm the FastAPI development server is still running in the Terminal from earlier.
- Check that main.py uses the exact path static/index.html.
- Confirm index.html appears directly inside static/.
- Help me debug the missing chat page.
✔️ Awesome, I've got everything!
Great. Double check that you saved main.py.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
from fastapi.responses import FileResponse
app = FastAPI()
@app.get("/")
async def index():
return FileResponse("static/index.html")
Add local message bubbles
The complete chat shell is visible. Its controls still need local behavior.
A small JavaScript handler captures each prompt. It adds both sides of the conversation to the transcript without contacting OpenCode Go.
- Return to static/index.html in your code editor.
- Paste this script directly before </body>:
<script>
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const sendButton = document.querySelector("#send-button");
const messages = document.querySelector("#messages");
const status = document.querySelector("#status");
function addMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = promptInput.value.trim();
if (!message) return;
addMessage("user", message);
const assistantBubble = addMessage("assistant", "");
promptInput.value = "";
});
</script>
How does the local interaction work?
- The element constants keep references to the form controls and transcript.
- The addMessage(role, text) function creates one styled message bubble.
- The submit handler stops the form from navigating away from the page.
- The handler adds the learner's text before creating an empty assistant bubble.
- Save static/index.html.
- Return to Chrome.
- Refresh http://127.0.0.1:8000.
Before you submit a prompt, how many new message bubbles do you expect the handler to add?
- Enter Explain Python loops. in the prompt box.
- Click Send.
You'll see your prompt in a blue user bubble. A small empty assistant bubble sits below it.
The status stays Ready. This check stays entirely inside the browser.
That is your first interactive win. Your browser can now build the conversation locally.
Prompt not appearing?
- Check that the <script> block sits directly before </body>.
- Confirm the page still uses the IDs chat-form and prompt.
- Refresh Chrome after saving static/index.html.
- Help me debug the local form handler.
✔️ Awesome, I've got everything!
Great. Double check that you saved static/index.html.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenCode Go Stream</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #08111f; color: #e5eefb; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #17315b 0, #08111f 45%); }
.app { width: min(760px, 100%); min-height: 620px; display: grid; grid-template-rows: auto 1fr auto auto; gap: 16px; padding: 24px; border: 1px solid #29466f; border-radius: 20px; background: rgba(8, 17, 31, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
header { display: flex; align-items: center; gap: 10px; }
h1 { margin: 0; font-size: 1.25rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: #3ddc97; box-shadow: 0 0 14px #3ddc97; }
#messages { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 14px; background: #0d1b2e; }
.message { max-width: 82%; padding: 12px 14px; border-radius: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.user { align-self: flex-end; background: #2f6fed; color: white; }
.assistant { align-self: flex-start; background: #172a45; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 54px; resize: vertical; padding: 14px; border: 1px solid #35547c; border-radius: 12px; background: #0d1b2e; color: inherit; font: inherit; }
button { padding: 0 20px; border: 0; border-radius: 12px; background: #3ddc97; color: #06120d; font-weight: 750; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.55; }
#status { min-height: 1.25rem; margin: 0; color: #9bb2cf; font-size: 0.9rem; }
@media (max-width: 600px) { body { padding: 0; } .app { min-height: 100vh; border: 0; border-radius: 0; } form { grid-template-columns: 1fr; } button { min-height: 48px; } }
</style>
</head>
<body>
<main class="app">
<header><span class="dot" aria-hidden="true"></span><h1>OpenCode Go Streaming Chat</h1></header>
<section id="messages" aria-live="polite"><div class="message assistant">Ask me to explain a programming concept.</div></section>
<form id="chat-form">
<label for="prompt" hidden>Message</label>
<textarea id="prompt" placeholder="Ask OpenCode Go..." required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
<p id="status" role="status">Ready</p>
</main>
<script>
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const sendButton = document.querySelector("#send-button");
const messages = document.querySelector("#messages");
const status = document.querySelector("#status");
function addMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = promptInput.value.trim();
if (!message) return;
addMessage("user", message);
const assistantBubble = addMessage("assistant", "");
promptInput.value = "";
});
</script>
</body>
</html>
Your chat shell is visible and responds to local prompts. Next up, you'll connect it to OpenCode Go through the backend and experience the delay of a complete-response request.
Connect to OpenCode Go
Your chat shell is live. FastAPI serves the page while the browser creates local message bubbles.
The next challenge is connecting that interface to OpenCode Go without exposing your API key. You will send each prompt through a backend API proxy that makes one complete provider request.
In this step, get ready to:
- Store the OpenCode Go API key in the backend environment.
- Send an OpenAI-compatible Chat Completions request through FastAPI.
- Display the completed provider reply in the browser.
Keep the API key on the backend
An environment variable gives the backend access to your secret without placing it in static/index.html. Browser code remains visible to anyone who opens the page or its developer tools.
This next action handles a live credential. Keep the value inside the Terminal session from earlier so it never enters your browser code or project files.
- Stop the running development server from the Terminal session using Control-C.
- Export your API key by replacing the plain text placeholder before running this command:
export OPENCODE_GO_API_KEY=your-api-key-here
Why store the key in Terminal?
The exported value becomes available to processes started from this Terminal session. main.py can read it without saving the secret in a file.
The browser sends only the prompt to your backend. Your API key stays on the server side of the proxy.
- Restart the FastAPI development server with the exported key by running:
fastapi dev
What does this command do?
The development server starts with access to OPENCODE_GO_API_KEY from the current Terminal environment. You should see the local server address http://127.0.0.1:8000 again.
- Switch back to main.py in your code editor.
- Replace the imports through the app = FastAPI() line with this backend configuration:
import os
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
app = FastAPI()
# Keep provider details in one place for every request
OPENCODE_URL = "https://opencode.ai/zen/go/v1/chat/completions"
MODEL = "glm-5.3-flash"
SESSION_ID = "nextwork-fastapi-streaming-demo"
# Describe the JSON body accepted from the browser
class ChatRequest(BaseModel):
message: str
What does this configuration do?
- The os module reads the API key from the backend environment.
- HTTPX sends the asynchronous request to the hosted provider endpoint.
- Pydantic uses ChatRequest to require a string called message in the browser request.
- OPENCODE_URL identifies the hosted Chat Completions endpoint.
- SESSION_ID provides a stable value for the x-opencode-session header.
- Save main.py.
The development server reloads the app. You should see its startup output without a Python traceback.
Seeing a reload error?
- Check that every import begins at the left edge of main.py.
- Confirm that the existing @app.get("/") route still appears below ChatRequest.
- Compare the quotation marks around OPENCODE_URL. Missing quotes cause a syntax error.
- Help me debug the backend configuration.
Send a complete provider request
The browser needs a same-origin endpoint that accepts its prompt. That endpoint adds authentication before sending an OpenAI-compatible Chat Completions request to OpenCode Go.
- Add the POST /api/chat route below the existing index() route in main.py by pasting this code:
@app.post("/api/chat")
async def chat(request: ChatRequest):
# Read the secret only from the backend environment
api_key = os.getenv("OPENCODE_GO_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="Set OPENCODE_GO_API_KEY before starting the server.")
# Reject empty prompts before contacting the provider
message = request.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Message cannot be empty.")
# Build an authenticated OpenAI-compatible request
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "nextwork-fastapi-streaming-demo/1.0",
"x-opencode-session": SESSION_ID,
}
payload = {"model": MODEL, "messages": [{"role": "user", "content": message}], "stream": False}
# Wait for one complete JSON response
async with httpx.AsyncClient(timeout=None) as client:
response = await client.post(OPENCODE_URL, headers=headers, json=payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=f"OpenCode Go returned HTTP {response.status_code}.")
return response.json()
Why use HTTPX here?
HTTPX exposes the outgoing HTTP request directly. You can see the authorization header and provider payload without hiding them behind another software development kit.
The asynchronous client also fits FastAPI's request handler. Its context manager closes the connection after the complete response arrives.
- The Authorization header adds the backend-only bearer credential.
- messages places the submitted prompt in the user role.
- stream: False asks the provider for one completed JSON response.
- response.json() returns that provider response to the browser.
- Save main.py.
FastAPI reloads automatically. You should see the server return to its ready state without a traceback.
Does the backend fail to reload?
- Check that the new route sits below the complete index() function.
- Confirm that False begins with a capital letter because it is a Python value.
- Check the indentation inside the headers dictionary and the payload dictionary.
- Help me fix the OpenCode Go route.
✔️ Awesome, I've got everything!
Great. Your backend now has its provider configuration and non-streaming chat route. Double check that you saved main.py.
ⓧ I'd like to double check the full code
Compare your complete main.py file with this version:
import os
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
app = FastAPI()
# Keep provider details in one place for every request
OPENCODE_URL = "https://opencode.ai/zen/go/v1/chat/completions"
MODEL = "glm-5.3-flash"
SESSION_ID = "nextwork-fastapi-streaming-demo"
# Describe the JSON body accepted from the browser
class ChatRequest(BaseModel):
message: str
@app.get("/")
async def index():
return FileResponse("static/index.html")
@app.post("/api/chat")
async def chat(request: ChatRequest):
# Read the secret only from the backend environment
api_key = os.getenv("OPENCODE_GO_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="Set OPENCODE_GO_API_KEY before starting the server.")
# Reject empty prompts before contacting the provider
message = request.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Message cannot be empty.")
# Build an authenticated OpenAI-compatible request
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "nextwork-fastapi-streaming-demo/1.0",
"x-opencode-session": SESSION_ID,
}
payload = {"model": MODEL, "messages": [{"role": "user", "content": message}], "stream": False}
# Wait for one complete JSON response
async with httpx.AsyncClient(timeout=None) as client:
response = await client.post(OPENCODE_URL, headers=headers, json=payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=f"OpenCode Go returned HTTP {response.status_code}.")
return response.json()
Connect the browser and observe the response
Your backend route is ready. The existing submit handler now needs to send the prompt to /api/chat instead of stopping after it creates local bubbles.
- Switch back to static/index.html in your code editor.
- Find the existing block that starts with form.addEventListener("submit".
- Replace that complete event-listener block with this version:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = promptInput.value.trim();
if (!message) return;
addMessage("user", message);
const assistantBubble = addMessage("assistant", "");
promptInput.value = "";
sendButton.disabled = true;
status.textContent = "Waiting for OpenCode Go...";
try {
// Send only the user's message to the backend
const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }) });
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
throw new Error(problem.detail || `Request failed with HTTP ${response.status}`);
}
// Render the completed provider reply
const data = await response.json();
assistantBubble.textContent = data.choices[0].message.content;
status.textContent = "Complete";
} catch (error) {
assistantBubble.textContent = "The response could not be completed.";
status.textContent = error.message;
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
What does this browser code do?
- fetch() sends the prompt to the same-origin /api/chat endpoint as JSON.
- response.ok separates successful provider responses from backend errors.
- data.choices[0].message.content selects the completed assistant text from the Chat Completions response.
- finally restores the Send button after success or failure.
- Save static/index.html.
- Refresh http://127.0.0.1:8000 in Chrome.
Before you send a prompt, how do you think the assistant reply will appear in the empty bubble?
- Enter Count from one to five slowly. in the prompt box.
- Click Send.
You will see the user bubble immediately. The assistant bubble stays blank until the entire reply appears at once.
That delay is intentional. You have proved the endpoint, model, API key, headers, and payload all work before adding streaming.
Does the request fail?
- Return to the Terminal from earlier to confirm that fastapi dev is still running.
- Restart the server from the same Terminal session if the status says to set OPENCODE_GO_API_KEY.
- Export a valid OpenCode Go key again if the status reports that OpenCode Go rejected the request.
- Help me debug the failed provider request.
✔️ Awesome, I've got everything!
Your browser now sends prompts through FastAPI and displays completed OpenCode Go replies. Double check that you saved static/index.html.
ⓧ I'd like to double check the full code
Compare your complete static/index.html file with this version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenCode Go Stream</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #08111f; color: #e5eefb; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #17315b 0, #08111f 45%); }
.app { width: min(760px, 100%); min-height: 620px; display: grid; grid-template-rows: auto 1fr auto auto; gap: 16px; padding: 24px; border: 1px solid #29466f; border-radius: 20px; background: rgba(8, 17, 31, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
header { display: flex; align-items: center; gap: 10px; }
h1 { margin: 0; font-size: 1.25rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: #3ddc97; box-shadow: 0 0 14px #3ddc97; }
#messages { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 14px; background: #0d1b2e; }
.message { max-width: 82%; padding: 12px 14px; border-radius: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.user { align-self: flex-end; background: #2f6fed; color: white; }
.assistant { align-self: flex-start; background: #172a45; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 54px; resize: vertical; padding: 14px; border: 1px solid #35547c; border-radius: 12px; background: #0d1b2e; color: inherit; font: inherit; }
button { padding: 0 20px; border: 0; border-radius: 12px; background: #3ddc97; color: #06120d; font-weight: 750; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.55; }
#status { min-height: 1.25rem; margin: 0; color: #9bb2cf; font-size: 0.9rem; }
@media (max-width: 600px) { body { padding: 0; } .app { min-height: 100vh; border: 0; border-radius: 0; } form { grid-template-columns: 1fr; } button { min-height: 48px; } }
</style>
</head>
<body>
<main class="app">
<header><span class="dot" aria-hidden="true"></span><h1>OpenCode Go Streaming Chat</h1></header>
<section id="messages" aria-live="polite"><div class="message assistant">Ask me to explain a programming concept.</div></section>
<form id="chat-form">
<label for="prompt" hidden>Message</label>
<textarea id="prompt" placeholder="Ask OpenCode Go..." required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
<p id="status" role="status">Ready</p>
</main>
<script>
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const sendButton = document.querySelector("#send-button");
const messages = document.querySelector("#messages");
const status = document.querySelector("#status");
function addMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = promptInput.value.trim();
if (!message) return;
addMessage("user", message);
const assistantBubble = addMessage("assistant", "");
promptInput.value = "";
sendButton.disabled = true;
status.textContent = "Waiting for OpenCode Go...";
try {
// Send only the user's message to the backend
const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }) });
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
throw new Error(problem.detail || `Request failed with HTTP ${response.status}`);
}
// Render the completed provider reply
const data = await response.json();
assistantBubble.textContent = data.choices[0].message.content;
status.textContent = "Complete";
} catch (error) {
assistantBubble.textContent = "The response could not be completed.";
status.textContent = error.message;
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
</script>
</body>
</html>
The hosted connection is proven, and your API key stays behind FastAPI. Next, you will replace the complete-response request with an SSE relay that forwards chunks as they arrive.
Relay the OpenCode Go SSE Stream
Your previous step proved that FastAPI can reach OpenCode Go through your backend. The assistant reply still appears only after the provider finishes generating it.
This step switches the provider request to Server-Sent Events. HTTPX keeps the upstream response open while your proxy relays each byte chunk.
In this step, get ready to:
- Define an async generator that requests streaming output from OpenCode Go.
- Relay raw response chunks while handling upstream HTTP failures.
- Return an SSE response from FastAPI and verify the stream with curl.
Prepare the streaming request
The provider request now belongs in an async generator. FastAPI can pull one chunk at a time from this generator.
The headers keep authentication on the backend. The payload asks the selected model to stream its response.
- Switch back to main.py in your code editor.
- Add the generator below the index() route by pasting this code:
async def open_code_events(message: str, api_key: str):
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": "nextwork-fastapi-streaming-demo/1.0",
"x-opencode-session": SESSION_ID,
}
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": message}],
"stream": True,
}
What does this code prepare?
- The open_code_events() generator receives the validated message plus the backend-only API key.
- The Authorization header authenticates the provider request without exposing the key to browser code.
- The x-opencode-session header supplies the stable session value defined earlier.
- The payload requests output from MODEL as a stream.
- Save main.py.
- Return to the server Terminal tab from earlier.
You should see the development server reload without a Python syntax failure.
Seeing a Python error after the reload?
- Confirm that open_code_events() starts after the complete index() route.
- Check that the closing braces for headers and payload align with their assignment lines.
- Check that every line inside open_code_events() uses four spaces of indentation.
- Help me debug the new async generator in main.py.
Keep the upstream response open
A streaming HTTP response must remain open while chunks are arriving. Nested async context managers give the client plus its response a clear lifetime.
- Add the streaming logic immediately after the payload dictionary in open_code_events() by pasting this code:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
OPENCODE_URL,
headers=headers,
json=payload,
) as response:
if response.status_code >= 400:
await response.aread()
yield (
'event: error\n'
f'data: {{"error":"OpenCode Go returned HTTP {response.status_code}."}}\n\n'
)
return
async for chunk in response.aiter_raw():
yield chunk
How does the relay stay safe?
- The outer context owns the asynchronous HTTP client. Its disabled timeout allows a long streamed answer to continue.
- The inner context keeps the provider response open while the generator is active.
- A provider status of 400 or greater becomes a small SSE error event for the downstream client.
- The aiter_raw() loop yields the original bytes without buffering the whole answer.
- Both context managers close their resources after success. They also close after failure or browser cancellation.
- Save main.py.
- Return to the server Terminal tab.
You should see another clean development-server reload. This confirms that the nested async blocks are valid Python.
Does the server fail to reload?
- Check that the first async with line remains inside open_code_events().
- Align the second async with block one indentation level inside the first context.
- Keep yield chunk inside the async for loop.
- Help me fix the nested HTTPX streaming blocks.
Return and verify the SSE response
The route needs StreamingResponse to consume the generator. Its response media type tells the client to treat the body as an SSE stream.
- In main.py find this response import:
from fastapi.responses import FileResponse
What are you changing?
The current import supports the root route that serves static/index.html. The API route now needs a response class that can pull values from an async generator.
- Add StreamingResponse to the same import so the line reads:
from fastapi.responses import FileResponse, StreamingResponse
Why add StreamingResponse?
StreamingResponse sends each yielded value to the client while the generator continues running. This preserves the provider's incremental delivery.
- Save main.py.
- Return to the server Terminal tab.
You should see the server reload with the new response import available.
- Keep the existing API key check inside chat().
- Keep the existing empty-message check inside chat().
- Delete the old non-streaming provider request code that follows the empty-message check.
- Add the streaming return in its place by pasting this code:
return StreamingResponse(
open_code_events(message, api_key),
media_type="text/event-stream",
)
What does the route return now?
- The route passes the validated message plus the backend-only key into open_code_events().
- StreamingResponse requests chunks from the generator as the client reads the response.
- The text/event-stream media type identifies the response as an SSE stream.
- Save main.py.
- Return to the server Terminal tab.
You should see a clean reload after the route switches to StreamingResponse.
Does the route fail after reloading?
- Confirm that StreamingResponse appears in the FastAPI responses import.
- Confirm that open_code_events() is defined above the chat() route.
- Remove any remaining non-streaming request code after the empty-message check.
- Help me debug the StreamingResponse route.
✔️ My main.py matches
Great. Your main.py now requests streaming output and relays raw SSE chunks.
ⓧ I'd like to double check the full code
Compare your complete main.py with this version:
import os
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
app = FastAPI()
OPENCODE_URL = "https://opencode.ai/zen/go/v1/chat/completions"
MODEL = "glm-5.3-flash"
SESSION_ID = "nextwork-fastapi-streaming-demo"
class ChatRequest(BaseModel):
message: str
@app.get("/")
async def index():
return FileResponse("static/index.html")
async def open_code_events(message: str, api_key: str):
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": "nextwork-fastapi-streaming-demo/1.0",
"x-opencode-session": SESSION_ID,
}
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": message}],
"stream": True,
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
OPENCODE_URL,
headers=headers,
json=payload,
) as response:
if response.status_code >= 400:
await response.aread()
yield (
'event: error\n'
f'data: {{"error":"OpenCode Go returned HTTP {response.status_code}."}}\n\n'
)
return
async for chunk in response.aiter_raw():
yield chunk
@app.post("/api/chat")
async def chat(request: ChatRequest):
api_key = os.getenv("OPENCODE_GO_API_KEY")
if not api_key:
raise HTTPException(
status_code=500,
detail="Set OPENCODE_GO_API_KEY before starting the server.",
)
message = request.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Message cannot be empty.")
return StreamingResponse(
open_code_events(message, api_key),
media_type="text/event-stream",
)
Before you run the check, do you expect one completed JSON document or several SSE records?
- Keep the FastAPI server running in the Terminal tab from earlier.
- Create another Terminal tab by pressing Cmd+T.
- Send a test prompt through the local proxy by running this command:
curl -N -X POST http://127.0.0.1:8000/api/chat -H "Content-Type: application/json" -d '{"message":"Count from one to five slowly."}'
What should you see?
Terminal prints multiple data: records as the model responds. The last record is data: [DONE].
Those separate records prove that FastAPI is forwarding the upstream response before the full answer finishes.
Do the SSE records fail to appear?
- Confirm that the FastAPI server remains running in the original Terminal tab.
- Confirm that the server was restarted after OPENCODE_GO_API_KEY was exported.
- Check that the test command targets http://127.0.0.1:8000/api/chat.
- Help me diagnose why the SSE test is not returning data records.
That transport layer is working: your backend now relays provider chunks as they arrive. Next, you'll teach the browser to reconstruct those records into a growing assistant reply.
Render Tokens as They Arrive
Your FastAPI backend now relays raw Server-Sent Events from OpenCode Go. Google Chrome can receive them, but the current browser code still expects one complete JSON document.
In this step, you will turn the response body into a stream of text updates. The assistant bubble will grow while the model is still responding.
In this step, get ready to:
- Read the response body one chunk at a time.
- Reconstruct complete SSE events from partial text.
- Render assistant text incrementally while reporting request errors.
Read and buffer the response stream
A streamed response exposes bytes as they arrive. The browser needs a reader to pull each chunk before it can interpret the SSE records inside.
- Switch back to static/index.html in the code editor from earlier.
- Locate the existing if (!response.ok) block inside the try block.
- Replace the response-reading lines directly below that block with this streaming parser:
if (!response.body) throw new Error("This browser did not provide a readable response body.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const rawEvent of events) {
const lines = rawEvent.split("\n");
const eventType = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n").trim();
if (!data || data === "[DONE]") continue;
const parsed = JSON.parse(data);
if (eventType === "error") throw new Error(parsed.error || "OpenCode Go rejected the request.");
const text = parsed.choices?.[0]?.delta?.content ?? "";
if (text) {
assistantBubble.textContent += text;
messages.scrollTop = messages.scrollHeight;
}
}
}
How Does the Browser Stream Work?
- The browser exposes response.body as a ReadableStream.
- Calling getReader() gives the browser one byte chunk at a time.
- The TextDecoder converts each byte chunk into text. The stream: true option preserves characters that span chunk boundaries.
- The buffer holds unfinished text until the parser finds a complete SSE event.
Reconstruct complete SSE events
An SSE event can arrive across several network chunks. The parser normalizes line endings before splitting complete events at blank lines.
- Leave one blank line below the closing brace of the while loop.
- Add the completion check below that blank line:
if (!assistantBubble.textContent) assistantBubble.textContent = "No text was returned.";
status.textContent = "Complete";
Why Keep a Completion Fallback?
The optional chain in parsed.choices?.[0]?.delta?.content ?? "" safely handles chunks with an empty choices array. The completion fallback gives the assistant bubble visible text when a stream ends without a text delta.
- Confirm the parser checks for data: [DONE] before calling JSON.parse(data).
- Confirm the eventType === "error" check appears before the text-delta extraction.
- Confirm each text delta is appended to assistantBubble.textContent inside the event loop.
Watch the assistant bubble grow
The browser now has everything it needs to turn framed SSE records into visible text. Before you send the prompt, do you expect the assistant bubble to fill once or grow across several updates?
- Save static/index.html.
- Switch back to the local chat page in Chrome.
- Refresh http://127.0.0.1:8000.
- Enter Count from one to five slowly. in the prompt box.
- Click Send.
You will see Streaming... in the status line while the assistant bubble grows several times. When the stream ends, the status changes to Complete.
You have the full streaming path working now. The API key remains on the FastAPI backend while Chrome receives incremental text.
Is the Reply Missing or Arriving All at Once?
- Confirm the FastAPI development server from earlier is still running.
- Compare the response-reading section with the full file below if the assistant bubble stays empty.
- Check the blank-line split if the status reports a parsing problem.
- Ask for help with debugging the browser stream parser.
✔️ Awesome, I've got everything!
Great. Double check that you saved static/index.html after adding the stream parser.
ⓧ I'd like to double check the full code
Compare your complete static/index.html with this version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenCode Go Stream</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #08111f; color: #e5eefb; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #17315b 0, #08111f 45%); }
.app { width: min(760px, 100%); min-height: 620px; display: grid; grid-template-rows: auto 1fr auto auto; gap: 16px; padding: 24px; border: 1px solid #29466f; border-radius: 20px; background: rgba(8, 17, 31, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
header { display: flex; align-items: center; gap: 10px; }
h1 { margin: 0; font-size: 1.25rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: #3ddc97; box-shadow: 0 0 14px #3ddc97; }
#messages { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 14px; background: #0d1b2e; }
.message { max-width: 82%; padding: 12px 14px; border-radius: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.user { align-self: flex-end; background: #2f6fed; color: white; }
.assistant { align-self: flex-start; background: #172a45; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 54px; resize: vertical; padding: 14px; border: 1px solid #35547c; border-radius: 12px; background: #0d1b2e; color: inherit; font: inherit; }
button { padding: 0 20px; border: 0; border-radius: 12px; background: #3ddc97; color: #06120d; font-weight: 750; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.55; }
#status { min-height: 1.25rem; margin: 0; color: #9bb2cf; font-size: 0.9rem; }
@media (max-width: 600px) { body { padding: 0; } .app { min-height: 100vh; border: 0; border-radius: 0; } form { grid-template-columns: 1fr; } button { min-height: 48px; } }
</style>
</head>
<body>
<main class="app">
<header><span class="dot" aria-hidden="true"></span><h1>OpenCode Go Streaming Chat</h1></header>
<section id="messages" aria-live="polite"><div class="message assistant">Ask me to explain a programming concept.</div></section>
<form id="chat-form">
<label for="prompt" hidden>Message</label>
<textarea id="prompt" placeholder="Ask OpenCode Go..." required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
<p id="status" role="status">Ready</p>
</main>
<script>
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const sendButton = document.querySelector("#send-button");
const messages = document.querySelector("#messages");
const status = document.querySelector("#status");
function addMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = promptInput.value.trim();
if (!message) return;
addMessage("user", message);
const assistantBubble = addMessage("assistant", "");
promptInput.value = "";
sendButton.disabled = true;
status.textContent = "Streaming...";
try {
const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }) });
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
throw new Error(problem.detail || `Request failed with HTTP ${response.status}`);
}
if (!response.body) throw new Error("This browser did not provide a readable response body.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const rawEvent of events) {
const lines = rawEvent.split("\n");
const eventType = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n").trim();
if (!data || data === "[DONE]") continue;
const parsed = JSON.parse(data);
if (eventType === "error") throw new Error(parsed.error || "OpenCode Go rejected the request.");
const text = parsed.choices?.[0]?.delta?.content ?? "";
if (text) {
assistantBubble.textContent += text;
messages.scrollTop = messages.scrollHeight;
}
}
}
if (!assistantBubble.textContent) assistantBubble.textContent = "No text was returned.";
status.textContent = "Complete";
} catch (error) {
assistantBubble.textContent ||= "The response could not be completed.";
status.textContent = error.message;
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
</script>
</body>
</html>
Secret mission
Add a Stop Button
A long answer can keep streaming after the user has enough information. Add a Stop button that cancels the active request immediately while keeping the page ready for another prompt.
Clean Up Your Resources
Clean Up Your Resources
Your opencode-stream-proxy folder and FastAPI server stay on your Mac, with three cleanup options: keep, pause, or delete. A stopped server sends no new requests to OpenCode Go.
Cost warning
OpenCode Go remains an existing $10/month subscription after local cleanup. Deleting this project does not cancel that subscription.
If Use balance is enabled, requests made after Go limits can use your Zen balance. Pausing or deleting the local app prevents accidental requests from this project.
Resources you used:
- The opencode-stream-proxy folder, including main.py, requirements.txt, .gitignore, static/index.html, and .venv.
- The FastAPI development server process that is still running in Terminal.
- The Terminal session that contains the exported OPENCODE_GO_API_KEY.
Keep everything running
No action is needed. Choose this option if you are still testing the streaming chat or plan to extend it soon.
- Keep the Terminal window open if you want the FastAPI server to remain available.
- Keep OPENCODE_GO_API_KEY private because the Terminal session contains the exported secret.
- Use the chat only when you want a request to consume your OpenCode Go allowance.
Pause - I'll come back to this later
This option frees your terminal while preserving the project files. You can restart the local proxy when you return.
- Stop the FastAPI server by pressing Control-C in the Terminal window where it is running.
- Deactivate .venv by running:
deactivate
What does this command do?
The command exits the active .venv session. Your project files and installed packages remain available.
Your local proxy is now paused without losing any code. Your browser cannot send another chat request until you restart FastAPI.
- Reactivate .venv later from the same opencode-stream-proxy folder by running:
source .venv/bin/activate
What does activation restore?
This restores the Python packages installed inside .venv to the current shell. Your global Python installation stays untouched.
- Restore OPENCODE_GO_API_KEY with the secure export method from Step 3 if you started a new Terminal session.
- Start the FastAPI development server by running:
fastapi dev
What should I see?
The development server starts at http://127.0.0.1:8000. Your streaming chat becomes available again at that address.
Delete - I don't want to use this again
This option removes the local code and its disposable virtual environment. Your existing OpenCode Go subscription remains unchanged.
- Stop the FastAPI server by pressing Control-C in the Terminal window where it is running.
- Deactivate .venv by running:
deactivate
Why deactivate first?
This exits the virtual environment before you remove its files. The Terminal returns to your normal shell environment.
Deleting the folder is permanent. The command below targets only opencode-stream-proxy from its parent folder.
- Remove opencode-stream-proxy from its parent folder by running these commands:
cd ..
rm -rf opencode-stream-proxy
What does this command remove?
The first line moves out of opencode-stream-proxy. The second line permanently removes the folder with its code and .venv environment.
- Confirm the project folder is gone by running:
ls
What should I see?
You should no longer see opencode-stream-proxy in the output. That confirms the local project has been removed.
- Close the Terminal window that contained OPENCODE_GO_API_KEY.
The closed shell no longer retains the exported key. This project created no OpenCode-side resource that needs deletion.
Nice Work!
Nice Work!
You did it! Your local chat app now streams hosted OpenCode Go responses through FastAPI as they arrive.
Your API key stays on the backend. The browser can cancel an active response without reloading the page.
What you learned:
- Create a secure API proxy that reads OPENCODE_GO_API_KEY on the backend. The browser never receives the secret.
- Relay Server-Sent Events from OpenCode Go with HTTPX. A FastAPI StreamingResponse forwards each upstream byte chunk.
- Build a browser stream parser for fragmented SSE records. Handle empty choices arrays safely. Ignore data: [DONE]. Append each choices[0].delta.content value to the assistant bubble.
- Complete the optional Secret Mission by adding a Stop button powered by AbortController. Cancellation stops rendering immediately. The page stays ready for another prompt.
Ready to quiz yourself?