summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-11-10 13:44:12 +0000
committerDavid Hill <[email protected]>2025-11-10 13:44:12 +0000
commitc6e830c954418808dc39284a1c073aa63a6d4d21 (patch)
tree9c3052e0509115188768a553c0be5a8441ebdd96 /packages/console/app/src
parent7088bfabd773e2f076aab1c9d2468c04feff0570 (diff)
parentfc78c28df64383a9f99382093f61fc28caf6569f (diff)
downloadopencode-c6e830c954418808dc39284a1c073aa63a6d4d21.tar.gz
opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/console/app/src')
-rw-r--r--packages/console/app/src/app.tsx2
-rw-r--r--packages/console/app/src/component/dropdown.css2
-rw-r--r--packages/console/app/src/component/modal.css2
-rw-r--r--packages/console/app/src/config.ts3
-rw-r--r--packages/console/app/src/lib/github.ts5
-rw-r--r--packages/console/app/src/routes/brand/index.css13
-rw-r--r--packages/console/app/src/routes/brand/index.tsx150
-rw-r--r--packages/console/app/src/routes/desktop-feedback.ts5
-rw-r--r--packages/console/app/src/routes/enterprise/index.css2
-rw-r--r--packages/console/app/src/routes/enterprise/index.tsx63
-rw-r--r--packages/console/app/src/routes/index.css2
-rw-r--r--packages/console/app/src/routes/index.tsx781
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts234
-rw-r--r--packages/console/app/src/routes/temp.tsx10
-rw-r--r--packages/console/app/src/routes/user-menu.css2
-rw-r--r--packages/console/app/src/routes/workspace-picker.css2
-rw-r--r--packages/console/app/src/routes/workspace.css2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css53
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx185
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx28
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css203
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx166
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx40
-rw-r--r--packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css9
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/model-section.tsx14
-rw-r--r--packages/console/app/src/routes/workspace/[id]/new-user-section.module.css2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/provider-section.module.css3
-rw-r--r--packages/console/app/src/routes/workspace/[id]/provider-section.tsx4
-rw-r--r--packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css6
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx38
-rw-r--r--packages/console/app/src/routes/zen/index.css2
-rw-r--r--packages/console/app/src/routes/zen/index.tsx4
-rw-r--r--packages/console/app/src/routes/zen/util/error.ts1
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts133
-rw-r--r--packages/console/app/src/routes/zen/util/provider/anthropic.ts35
-rw-r--r--packages/console/app/src/routes/zen/util/provider/openai-compatible.ts4
-rw-r--r--packages/console/app/src/routes/zen/util/provider/openai.ts41
-rw-r--r--packages/console/app/src/routes/zen/util/rateLimiter.ts35
-rw-r--r--packages/console/app/src/routes/zen/v1/models.ts5
41 files changed, 1054 insertions, 1241 deletions
diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx
index bc3961214..1cf963642 100644
--- a/packages/console/app/src/app.tsx
+++ b/packages/console/app/src/app.tsx
@@ -12,7 +12,7 @@ export default function App() {
root={(props) => (
<MetaProvider>
<Title>opencode</Title>
- <Meta name="description" content="opencode - The AI coding agent built for the terminal." />
+ <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
diff --git a/packages/console/app/src/component/dropdown.css b/packages/console/app/src/component/dropdown.css
index 982367c6b..242940e6a 100644
--- a/packages/console/app/src/component/dropdown.css
+++ b/packages/console/app/src/component/dropdown.css
@@ -77,4 +77,4 @@
background-color: var(--color-accent-alpha);
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css
index 23b6831c9..1f47f395d 100644
--- a/packages/console/app/src/component/modal.css
+++ b/packages/console/app/src/component/modal.css
@@ -63,4 +63,4 @@
font-weight: 600;
color: var(--color-text);
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts
index 097764b87..40108e968 100644
--- a/packages/console/app/src/config.ts
+++ b/packages/console/app/src/config.ts
@@ -2,6 +2,9 @@
* Application-wide constants and configuration
*/
export const config = {
+ // Base URL
+ baseUrl: "https://opencode.ai",
+
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
diff --git a/packages/console/app/src/lib/github.ts b/packages/console/app/src/lib/github.ts
index dab317751..bc49d2e62 100644
--- a/packages/console/app/src/lib/github.ts
+++ b/packages/console/app/src/lib/github.ts
@@ -7,10 +7,7 @@ export const github = query(async () => {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
}
- const apiBaseUrl = config.github.repoUrl.replace(
- "https://github.com/",
- "https://api.github.com/repos/",
- )
+ const apiBaseUrl = config.github.repoUrl.replace("https://github.com/", "https://api.github.com/repos/")
try {
const [meta, releases, contributors] = await Promise.all([
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
diff --git a/packages/console/app/src/routes/brand/index.css b/packages/console/app/src/routes/brand/index.css
index 5a445f298..d3c0d0523 100644
--- a/packages/console/app/src/routes/brand/index.css
+++ b/packages/console/app/src/routes/brand/index.css
@@ -264,7 +264,7 @@
[data-component="brand-content"] {
padding: 4rem 5rem;
- h2 {
+ h1 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
@@ -299,7 +299,6 @@
transition: all 0.2s ease;
text-decoration: none;
-
&:hover:not(:disabled) {
background: var(--color-background-strong-hover);
}
@@ -385,23 +384,21 @@
0 1px 2px -1px rgba(19, 16, 16, 0.12);
@media (max-width: 40rem) {
- box-shadow:
- 0 0 0 1px rgba(19, 16, 16, 0.16)
+ box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.16);
}
-
&:hover {
background: var(--color-background);
}
&:active {
transform: scale(0.98);
- box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.08), 0 6px 8px -8px rgba(19, 16, 16, 0.50);
+ box-shadow:
+ 0 0 0 1px rgba(19, 16, 16, 0.08),
+ 0 6px 8px -8px rgba(19, 16, 16, 0.5);
}
}
-
-
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
diff --git a/packages/console/app/src/routes/brand/index.tsx b/packages/console/app/src/routes/brand/index.tsx
index 4570c73ca..6aac4517a 100644
--- a/packages/console/app/src/routes/brand/index.tsx
+++ b/packages/console/app/src/routes/brand/index.tsx
@@ -1,6 +1,7 @@
import "./index.css"
-import { Title, Meta } from "@solidjs/meta"
+import { Title, Meta, Link } from "@solidjs/meta"
import { Header } from "~/component/header"
+import { config } from "~/config"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
@@ -53,26 +54,21 @@ export default function Brand() {
return (
<main data-page="enterprise">
<Title>OpenCode | Brand</Title>
+ <Link rel="canonical" href={`${config.baseUrl}/brand`} />
<Meta name="description" content="OpenCode brand guidelines" />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="brand-content">
- <h2>Brand guidelines</h2>
+ <h1>Brand guidelines</h1>
<p>Resources and assets to help you work with the OpenCode brand.</p>
<button
data-component="download-button"
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
>
Download all assets
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -88,13 +84,7 @@ export default function Brand() {
<div data-component="actions">
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -105,13 +95,7 @@ export default function Brand() {
</button>
<button onClick={() => downloadFile(logoLightSvg, "opencode-logo-light.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -127,13 +111,7 @@ export default function Brand() {
<div data-component="actions">
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -144,13 +122,7 @@ export default function Brand() {
</button>
<button onClick={() => downloadFile(logoDarkSvg, "opencode-logo-dark.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -164,17 +136,9 @@ export default function Brand() {
<div>
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
- <button
- onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}
- >
+ <button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -183,17 +147,9 @@ export default function Brand() {
/>
</svg>
</button>
- <button
- onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}
- >
+ <button onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -207,17 +163,9 @@ export default function Brand() {
<div>
<img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
- <button
- onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}
- >
+ <button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -226,17 +174,9 @@ export default function Brand() {
/>
</svg>
</button>
- <button
- onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}
- >
+ <button onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -250,19 +190,9 @@ export default function Brand() {
<div>
<img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
- <button
- onClick={() =>
- downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")
- }
- >
+ <button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -271,19 +201,9 @@ export default function Brand() {
/>
</svg>
</button>
- <button
- onClick={() =>
- downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")
- }
- >
+ <button onClick={() => downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -297,19 +217,9 @@ export default function Brand() {
<div>
<img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
- <button
- onClick={() =>
- downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")
- }
- >
+ <button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
PNG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -318,19 +228,9 @@ export default function Brand() {
/>
</svg>
</button>
- <button
- onClick={() =>
- downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")
- }
- >
+ <button onClick={() => downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")}>
SVG
- <svg
- width="20"
- height="20"
- viewBox="0 0 20 20"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
diff --git a/packages/console/app/src/routes/desktop-feedback.ts b/packages/console/app/src/routes/desktop-feedback.ts
new file mode 100644
index 000000000..1916cdb4c
--- /dev/null
+++ b/packages/console/app/src/routes/desktop-feedback.ts
@@ -0,0 +1,5 @@
+import { redirect } from "@solidjs/router"
+
+export async function GET() {
+ return redirect("https://discord.gg/h5TNnkFVNy")
+}
diff --git a/packages/console/app/src/routes/enterprise/index.css b/packages/console/app/src/routes/enterprise/index.css
index 567c4f99d..0178e40a2 100644
--- a/packages/console/app/src/routes/enterprise/index.css
+++ b/packages/console/app/src/routes/enterprise/index.css
@@ -287,7 +287,7 @@
}
[data-component="enterprise-column-1"] {
- h2 {
+ h1 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx
index 4af0ccce8..095ed97a2 100644
--- a/packages/console/app/src/routes/enterprise/index.tsx
+++ b/packages/console/app/src/routes/enterprise/index.tsx
@@ -1,6 +1,7 @@
import "./index.css"
-import { Title, Meta } from "@solidjs/meta"
+import { Title, Meta, Link } from "@solidjs/meta"
import { createSignal, Show } from "solid-js"
+import { config } from "~/config"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
@@ -54,6 +55,7 @@ export default function Enterprise() {
return (
<main data-page="enterprise">
<Title>OpenCode | Enterprise solutions for your organisation</Title>
+ <Link rel="canonical" href={`${config.baseUrl}/enterprise`} />
<Meta name="description" content="Contact OpenCode for enterprise solutions" />
<div data-component="container">
<Header />
@@ -62,41 +64,28 @@ export default function Enterprise() {
<section data-component="enterprise-content">
<div data-component="enterprise-columns">
<div data-component="enterprise-column-1">
- <h2>Your code is yours</h2>
+ <h1>Your code is yours</h1>
<p>
- OpenCode operates securely inside your organization with no data or context stored
- and no licensing restrictions or ownership claims. Start a trial with your team,
- then deploy it across your organization by integrating it with your SSO and
- internal AI gateway.
+ OpenCode operates securely inside your organization with no data or context stored and no licensing
+ restrictions or ownership claims. Start a trial with your team, then deploy it across your
+ organization by integrating it with your SSO and internal AI gateway.
</p>
<p>Let us know and how we can help.</p>
<Show when={false}>
<div data-component="testimonial">
<div data-component="quotation">
- <svg
- width="20"
- height="17"
- viewBox="0 0 20 17"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
fill="currentColor"
/>
</svg>
</div>
- Thanks to OpenCode, we found a way to create software to track all our assets —
- even the imaginary ones.
+ Thanks to OpenCode, we found a way to create software to track all our assets — even the imaginary
+ ones.
<div data-component="testimonial-logo">
- <svg
- width="80"
- height="79"
- viewBox="0 0 80 79"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="80" height="79" viewBox="0 0 80 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
@@ -213,11 +202,7 @@ export default function Enterprise() {
</button>
</form>
- {showSuccess() && (
- <div data-component="success-message">
- Message sent, we'll be in touch soon.
- </div>
- )}
+ {showSuccess() && <div data-component="success-message">Message sent, we'll be in touch soon.</div>}
</div>
</div>
</div>
@@ -230,31 +215,29 @@ export default function Enterprise() {
<ul>
<li>
<Faq question="What is OpenCode Enterprise?">
- OpenCode Enterprise is for organizations that want to ensure that their code and
- data never leaves their infrastructure. It can do this by using a centralized
- config that integrates with your SSO and internal AI gateway.
+ OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves
+ their infrastructure. It can do this by using a centralized config that integrates with your SSO and
+ internal AI gateway.
</Faq>
</li>
<li>
<Faq question="How do I get started with OpenCode Enterprise?">
- Simply start with an internal trial with your team. OpenCode by default does not
- store your code or context data, making it easy to get started. Then contact us to
- discuss pricing and implementation options.
+ Simply start with an internal trial with your team. OpenCode by default does not store your code or
+ context data, making it easy to get started. Then contact us to discuss pricing and implementation
+ options.
</Faq>
</li>
<li>
<Faq question="How does enterprise pricing work?">
- We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not
- charge for tokens used. For further details, contact us for a custom quote based
- on your organization's needs.
+ We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens
+ used. For further details, contact us for a custom quote based on your organization's needs.
</Faq>
</li>
<li>
<Faq question="Is my data secure with OpenCode Enterprise?">
- Yes. OpenCode does not store your code or context data. All processing happens
- locally or through direct API calls to your AI provider. With central config and
- SSO integration, your data remains secure within your organization's
- infrastructure.
+ Yes. OpenCode does not store your code or context data. All processing happens locally or through
+ direct API calls to your AI provider. With central config and SSO integration, your data remains
+ secure within your organization's infrastructure.
</Faq>
</li>
</ul>
diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css
index 496901339..e5101442f 100644
--- a/packages/console/app/src/routes/index.css
+++ b/packages/console/app/src/routes/index.css
@@ -479,7 +479,7 @@ body {
border-bottom: 1px solid var(--color-border-weak);
}
- strong {
+ h1 {
font-size: 28px;
color: var(--color-text-strong);
font-weight: 500;
diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx
index bcfb22bc3..8b8f44999 100644
--- a/packages/console/app/src/routes/index.tsx
+++ b/packages/console/app/src/routes/index.tsx
@@ -42,11 +42,9 @@ export default function Home() {
return (
<main data-page="opencode">
- <HttpHeader
- name="Cache-Control"
- value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400"
- />
+ <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
<Title>OpenCode | The AI coding agent built for the terminal</Title>
+ <Link rel="canonical" href={config.baseUrl} />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
@@ -56,27 +54,17 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-slot="hero-copy">
- <a
- data-slot="releases"
- href={release()?.url ?? `${config.github.repoUrl}/releases`}
- target="_blank"
- >
+ <a data-slot="releases" href={release()?.url ?? `${config.github.repoUrl}/releases`} target="_blank">
What’s new in {release()?.name ?? "the latest release"}
</a>
- <strong>The AI coding agent built for the terminal</strong>
+ <h1>The AI coding agent built for the terminal</h1>
<p>
- OpenCode is fully open source, giving you control and freedom to use any provider,
- any model, and any editor.
+ OpenCode is fully open source, giving you control and freedom to use any provider, any model, and any
+ editor.
</p>
<a href="/docs">
<span>Read docs </span>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
stroke="currentColor"
@@ -175,10 +163,7 @@ export default function Home() {
<section data-component="what">
<div data-slot="section-title">
<h3>What is OpenCode?</h3>
- <p>
- OpenCode is an open source agent that helps you write and run code directly from the
- terminal.
- </p>
+ <p>OpenCode is an open source agent that helps you write and run code directly from the terminal.</p>
</div>
<ul>
<li>
@@ -196,8 +181,7 @@ export default function Home() {
<li>
<span>[*]</span>
<div>
- <strong>Multi-session</strong> Start multiple agents in parallel on the same
- project
+ <strong>Multi-session</strong> Start multiple agents in parallel on the same project
</div>
</li>
<li>
@@ -209,15 +193,13 @@ export default function Home() {
<li>
<span>[*]</span>
<div>
- <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max
- account
+ <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
</div>
</li>
<li>
<span>[*]</span>
<div>
- <strong>Any model</strong> 75+ LLM providers through Models.dev, including local
- models
+ <strong>Any model</strong> 75+ LLM providers through Models.dev, including local models
</div>
</li>
<li>
@@ -237,21 +219,15 @@ export default function Home() {
<p>
With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
<strong>{config.stats.contributors}</strong> contributors, and almost{" "}
- <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by
- over <strong>{config.stats.monthlyUsers}</strong> developers every month.
+ <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
+ <strong>{config.stats.monthlyUsers}</strong> developers every month.
</p>
</div>
<div data-component="growth-stats">
<div data-component="growth-stat">
<div data-component="stat-illustration">
- <svg
- width="205"
- height="264"
- viewBox="0 0 205 264"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5" clip-path="url(#clip0_236_15902)">
<mask
id="mask0_236_15902"
@@ -297,20 +273,13 @@ export default function Home() {
</svg>
</div>
<span>
- <figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong>{" "}
- GitHub Stars
+ <figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong> GitHub Stars
</span>
</div>
<div data-component="growth-stat">
<div data-component="stat-illustration">
- <svg
- width="205"
- height="264"
- viewBox="0 0 205 264"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5" clip-path="url(#clip0_236_15557)">
<g clip-path="url(#clip1_236_15557)">
<rect opacity="0.81" width="6" height="6" fill="#CFCECD" />
@@ -439,54 +408,12 @@ export default function Home() {
<rect opacity="0.32" x="70" y="112" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.52" x="84" y="112" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.02" x="98" y="112" width="6" height="6" fill="#CFCECD" />
- <rect
- opacity="0.88"
- x="126"
- y="112"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.12"
- x="140"
- y="112"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.93"
- x="154"
- y="112"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.79"
- x="168"
- y="112"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.24"
- x="182"
- y="112"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.64"
- x="196"
- y="112"
- width="6"
- height="6"
- fill="#CFCECD"
- />
+ <rect opacity="0.88" x="126" y="112" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.12" x="140" y="112" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.93" x="154" y="112" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.79" x="168" y="112" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.24" x="182" y="112" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.64" x="196" y="112" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.57" y="126" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.6" x="14" y="126" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.05" x="28" y="126" width="6" height="6" fill="#BCBBBB" />
@@ -495,55 +422,13 @@ export default function Home() {
<rect opacity="0.93" x="70" y="126" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.63" x="84" y="126" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.58" x="98" y="126" width="6" height="6" fill="#DAD9D9" />
- <rect
- opacity="0.64"
- x="112"
- y="126"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.74"
- x="126"
- y="126"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.74"
- x="140"
- y="126"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.64" x="112" y="126" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.74" x="126" y="126" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.74" x="140" y="126" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.1" x="154" y="126" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.93"
- x="168"
- y="126"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.43"
- x="182"
- y="126"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.45"
- x="196"
- y="126"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
+ <rect opacity="0.93" x="168" y="126" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.43" x="182" y="126" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.45" x="196" y="126" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.77" y="140" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.78" x="14" y="140" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.18" x="28" y="140" width="6" height="6" fill="#DAD9D9" />
@@ -552,55 +437,13 @@ export default function Home() {
<rect opacity="0.53" x="70" y="140" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.06" x="84" y="140" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.81" x="98" y="140" width="6" height="6" fill="#DAD9D9" />
- <rect
- opacity="0.49"
- x="112"
- y="140"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.45"
- x="126"
- y="140"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.37"
- x="140"
- y="140"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.58"
- x="154"
- y="140"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.49" x="112" y="140" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.45" x="126" y="140" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.37" x="140" y="140" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.58" x="154" y="140" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.8" x="168" y="140" width="6" height="6" fill="#BCBBBB" />
- <rect
- opacity="0.35"
- x="182"
- y="140"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.73"
- x="196"
- y="140"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.35" x="182" y="140" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.73" x="196" y="140" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.92" y="154" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.32" x="14" y="154" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.3" x="28" y="154" width="6" height="6" fill="#8E8B8B" />
@@ -609,47 +452,12 @@ export default function Home() {
<rect opacity="0.66" x="70" y="154" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.83" x="84" y="154" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.52" x="98" y="154" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.82"
- x="112"
- y="154"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.95"
- x="126"
- y="154"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.89"
- x="140"
- y="154"
- width="6"
- height="6"
- fill="#CFCECD"
- />
+ <rect opacity="0.82" x="112" y="154" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.95" x="126" y="154" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.89" x="140" y="154" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.2" x="154" y="154" width="6" height="6" fill="#BCBBBB" />
- <rect
- opacity="0.61"
- x="168"
- y="154"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.34"
- x="196"
- y="154"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
+ <rect opacity="0.61" x="168" y="154" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.34" x="196" y="154" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.9" y="168" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.99" x="14" y="168" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.49" x="28" y="168" width="6" height="6" fill="#BCBBBB" />
@@ -658,55 +466,13 @@ export default function Home() {
<rect opacity="0.92" x="70" y="168" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.79" x="84" y="168" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.8" x="98" y="168" width="6" height="6" fill="#BCBBBB" />
- <rect
- opacity="0.74"
- x="112"
- y="168"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.38"
- x="126"
- y="168"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.56"
- x="140"
- y="168"
- width="6"
- height="6"
- fill="#CFCECD"
- />
+ <rect opacity="0.74" x="112" y="168" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.38" x="126" y="168" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.56" x="140" y="168" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.7" x="154" y="168" width="6" height="6" fill="#DAD9D9" />
- <rect
- opacity="0.47"
- x="168"
- y="168"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.92"
- x="182"
- y="168"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.19"
- x="196"
- y="168"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
+ <rect opacity="0.47" x="168" y="168" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.92" x="182" y="168" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.19" x="196" y="168" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.12" y="182" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.16" x="14" y="182" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.98" x="28" y="182" width="6" height="6" fill="#8E8B8B" />
@@ -715,55 +481,13 @@ export default function Home() {
<rect opacity="0.17" x="70" y="182" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.26" x="84" y="182" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.3" x="98" y="182" width="6" height="6" fill="#DAD9D9" />
- <rect
- opacity="0.12"
- x="112"
- y="182"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.31"
- x="126"
- y="182"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.62"
- x="140"
- y="182"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.74"
- x="154"
- y="182"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
+ <rect opacity="0.12" x="112" y="182" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.31" x="126" y="182" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.62" x="140" y="182" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.74" x="154" y="182" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.8" x="168" y="182" width="6" height="6" fill="#CFCECD" />
- <rect
- opacity="0.89"
- x="182"
- y="182"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.75"
- x="196"
- y="182"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
+ <rect opacity="0.89" x="182" y="182" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.75" x="196" y="182" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.1" y="196" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.11" x="14" y="196" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.79" x="28" y="196" width="6" height="6" fill="#BCBBBB" />
@@ -772,62 +496,13 @@ export default function Home() {
<rect opacity="0.31" x="70" y="196" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.33" x="84" y="196" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.2" x="98" y="196" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.21"
- x="112"
- y="196"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.02"
- x="126"
- y="196"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.82"
- x="140"
- y="196"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.28"
- x="154"
- y="196"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.19"
- x="168"
- y="196"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.97"
- x="182"
- y="196"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.45"
- x="196"
- y="196"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
+ <rect opacity="0.21" x="112" y="196" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.02" x="126" y="196" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.82" x="140" y="196" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.28" x="154" y="196" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.19" x="168" y="196" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.97" x="182" y="196" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.45" x="196" y="196" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.88" y="210" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.58" x="14" y="210" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.53" x="28" y="210" width="6" height="6" fill="#BCBBBB" />
@@ -836,55 +511,13 @@ export default function Home() {
<rect opacity="0.73" x="70" y="210" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.87" x="84" y="210" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.35" x="98" y="210" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.61"
- x="112"
- y="210"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.61" x="112" y="210" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.8" x="126" y="210" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.87"
- x="140"
- y="210"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.77"
- x="154"
- y="210"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.94"
- x="168"
- y="210"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.59"
- x="182"
- y="210"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.37"
- x="196"
- y="210"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.87" x="140" y="210" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.77" x="154" y="210" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.94" x="168" y="210" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.59" x="182" y="210" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.37" x="196" y="210" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.7" y="224" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.72" x="14" y="224" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.95" x="28" y="224" width="6" height="6" fill="#CFCECD" />
@@ -894,54 +527,12 @@ export default function Home() {
<rect opacity="0.2" x="84" y="224" width="6" height="6" fill="#BCBBBB" />
<rect opacity="0.63" x="98" y="224" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.5" x="112" y="224" width="6" height="6" fill="#8E8B8B" />
- <rect
- opacity="0.79"
- x="126"
- y="224"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.02"
- x="140"
- y="224"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.17"
- x="154"
- y="224"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.99"
- x="168"
- y="224"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.82"
- x="182"
- y="224"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.28"
- x="196"
- y="224"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
+ <rect opacity="0.79" x="126" y="224" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.02" x="140" y="224" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.17" x="154" y="224" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.99" x="168" y="224" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.82" x="182" y="224" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.28" x="196" y="224" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.76" y="238" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.39" x="14" y="238" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.14" x="28" y="238" width="6" height="6" fill="#8E8B8B" />
@@ -950,62 +541,13 @@ export default function Home() {
<rect opacity="0.13" x="70" y="238" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.35" x="84" y="238" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.13" x="98" y="238" width="6" height="6" fill="#BCBBBB" />
- <rect
- opacity="0.55"
- x="112"
- y="238"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.83"
- x="126"
- y="238"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.86"
- x="140"
- y="238"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.63"
- x="154"
- y="238"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.38"
- x="168"
- y="238"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.57"
- x="182"
- y="238"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.13"
- x="196"
- y="238"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.55" x="112" y="238" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.83" x="126" y="238" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.86" x="140" y="238" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.63" x="154" y="238" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.38" x="168" y="238" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.57" x="182" y="238" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.13" x="196" y="238" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.9" y="252" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.63" x="14" y="252" width="6" height="6" fill="#CFCECD" />
<rect opacity="0.23" x="28" y="252" width="6" height="6" fill="#8E8B8B" />
@@ -1014,54 +556,12 @@ export default function Home() {
<rect opacity="0.19" x="70" y="252" width="6" height="6" fill="#DAD9D9" />
<rect opacity="0.29" x="84" y="252" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.78" x="98" y="252" width="6" height="6" fill="#BCBBBB" />
- <rect
- opacity="0.14"
- x="112"
- y="252"
- width="6"
- height="6"
- fill="#BCBBBB"
- />
- <rect
- opacity="0.64"
- x="126"
- y="252"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
- <rect
- opacity="0.27"
- x="140"
- y="252"
- width="6"
- height="6"
- fill="#CFCECD"
- />
- <rect
- opacity="0.85"
- x="154"
- y="252"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.02"
- x="168"
- y="252"
- width="6"
- height="6"
- fill="#DAD9D9"
- />
- <rect
- opacity="0.29"
- x="182"
- y="252"
- width="6"
- height="6"
- fill="#8E8B8B"
- />
+ <rect opacity="0.14" x="112" y="252" width="6" height="6" fill="#BCBBBB" />
+ <rect opacity="0.64" x="126" y="252" width="6" height="6" fill="#8E8B8B" />
+ <rect opacity="0.27" x="140" y="252" width="6" height="6" fill="#CFCECD" />
+ <rect opacity="0.85" x="154" y="252" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.02" x="168" y="252" width="6" height="6" fill="#DAD9D9" />
+ <rect opacity="0.29" x="182" y="252" width="6" height="6" fill="#8E8B8B" />
<rect opacity="0.4" x="196" y="252" width="6" height="6" fill="#8E8B8B" />
</g>
</g>
@@ -1070,31 +570,19 @@ export default function Home() {
<rect width="205" height="264" fill="white" />
</clipPath>
<clipPath id="clip1_236_15557">
- <rect
- width="236"
- height="264"
- fill="white"
- transform="translate(-0.164062)"
- />
+ <rect width="236" height="264" fill="white" transform="translate(-0.164062)" />
</clipPath>
</defs>
</svg>
</div>
<span>
- <figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong>{" "}
- Contributors
+ <figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong> Contributors
</span>
</div>
<div data-component="growth-stat">
<div data-component="stat-illustration">
- <svg
- width="205"
- height="264"
- viewBox="0 0 205 264"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="205" height="264" viewBox="0 0 205 264" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
<path d="M205 0H203.985V264H205V0Z" fill="#8E8B8B" />
<path d="M197.896 34H196.881V264H197.896V34Z" fill="#8E8B8B" />
@@ -1130,8 +618,7 @@ export default function Home() {
</svg>
</div>
<span>
- <figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly
- Devs
+ <figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly Devs
</span>
</div>
</div>
@@ -1145,9 +632,8 @@ export default function Home() {
<span>[*]</span>
<p>
- OpenCode does not store any of your code or context data, so that it can operate
- in privacy sensitive environments. Learn more about{" "}
- <a href="/docs/enterprise/ ">privacy</a>.
+ OpenCode does not store any of your code or context data, so that it can operate in privacy sensitive
+ environments. Learn more about <a href="/docs/enterprise/ ">privacy</a>.
</p>
</div>
</div>
@@ -1160,9 +646,9 @@ export default function Home() {
<ul>
<li>
<Faq question="What is OpenCode?">
- OpenCode is an open source agent that helps you write and run code directly from
- the terminal. You can pair OpenCode with any AI model, and because it’s
- terminal-based you can pair it with your preferred code editor.
+ OpenCode is an open source agent that helps you write and run code directly from the terminal. You can
+ pair OpenCode with any AI model, and because it’s terminal-based you can pair it with your preferred
+ code editor.
</Faq>
</li>
<li>
@@ -1172,32 +658,30 @@ export default function Home() {
</li>
<li>
<Faq question="Do I need extra AI subscriptions to use OpenCode?">
- Not necessarily, but probably. You’ll need an AI subscription if you want to
- connect OpenCode to a paid provider, although you can work with{" "}
+ Not necessarily, but probably. You’ll need an AI subscription if you want to connect OpenCode to a
+ paid provider, although you can work with{" "}
<a href="/docs/providers/#lm-studio" target="_blank">
local models
</a>{" "}
- for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works
- with all popular providers such as OpenAI, Anthropic, xAI etc.
+ for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works with all popular
+ providers such as OpenAI, Anthropic, xAI etc.
</Faq>
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
- Yes, for now. We are actively working on a desktop app. Join the waitlist for
- early access.
+ Yes, for now. We are actively working on a desktop app. Join the waitlist for early access.
</Faq>
</li>
<li>
<Faq question="How much does OpenCode cost?">
- OpenCode is 100% free to use. Any additional costs will come from your
- subscription to a model provider. While OpenCode works with any model provider, we
- recommend using <A href="/zen">Zen</A>.
+ OpenCode is 100% free to use. Any additional costs will come from your subscription to a model
+ provider. While OpenCode works with any model provider, we recommend using <A href="/zen">Zen</A>.
</Faq>
</li>
<li>
<Faq question="What about data and privacy?">
- Your data and information is only stored when you create sharable links in
- OpenCode. Learn more about <a href="/docs/share/#privacy">share pages</a>.
+ Your data and information is only stored when you create sharable links in OpenCode. Learn more about{" "}
+ <a href="/docs/share/#privacy">share pages</a>.
</Faq>
</li>
<li>
@@ -1210,8 +694,8 @@ export default function Home() {
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
MIT License
</a>
- , meaning anyone can use, modify, or contribute to its development. Anyone from
- the community can file issues, submit pull requests, and extend functionality.
+ , meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
+ issues, submit pull requests, and extend functionality.
</Faq>
</li>
</ul>
@@ -1221,19 +705,13 @@ export default function Home() {
<div data-slot="zen-cta-copy">
<strong>Access reliable optimized models for coding agents</strong>
<p>
- Zen gives you access to a handpicked set of AI models that OpenCode has tested and
- benchmarked specifically for coding agents. No need to worry about inconsistent
- performance and quality across providers, use validated models that work.
+ Zen gives you access to a handpicked set of AI models that OpenCode has tested and benchmarked
+ specifically for coding agents. No need to worry about inconsistent performance and quality across
+ providers, use validated models that work.
</p>
<div data-slot="model-logos">
<div>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask
id="mask0_79_128586"
style="mask-type:luminance"
@@ -1254,17 +732,8 @@ export default function Home() {
</svg>
</div>
<div>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z"
- fill="currentColor"
- />
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z" fill="currentColor" />
<path
d="M6.32538 13.6824L8.52662 8.01177L10.7279 13.6824H6.32538ZM6.68225 3.93164L0.25 20.0677H3.84652L5.16202 16.6791H11.8914L13.2067 20.0677H16.8033L10.371 3.93164H6.68225Z"
fill="currentColor"
@@ -1272,13 +741,7 @@ export default function Home() {
</svg>
</div>
<div>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.16861 16.0529L17.2018 9.85156C17.5957 9.54755 18.1586 9.66612 18.3463 10.1384C19.3339 12.6288 18.8926 15.6217 16.9276 17.6766C14.9626 19.7314 12.2285 20.1821 9.72948 19.1557L6.9995 20.4775C10.9151 23.2763 15.6699 22.5841 18.6411 19.4749C20.9979 17.0103 21.7278 13.6508 21.0453 10.6214L21.0515 10.6278C20.0617 6.17736 21.2948 4.39847 23.8207 0.760904C23.8804 0.674655 23.9402 0.588405 24 0.5L20.6762 3.97585V3.96506L9.16658 16.0551"
fill="currentColor"
@@ -1290,13 +753,7 @@ export default function Home() {
</svg>
</div>
<div>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
@@ -1306,13 +763,7 @@ export default function Home() {
</svg>
</div>
<div>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.6241 11.346L20.3848 3.44816C20.5309 3.29931 20.4487 3 20.2601 3H16.0842C16.0388 3 15.9949 3.01897 15.9594 3.05541L7.59764 11.5629C7.46721 11.6944 7.27446 11.5771 7.27446 11.3666V3.25183C7.27446 3.11242 7.18515 3 7.07594 3H4.19843C4.08932 3 4 3.11242 4 3.25183V20.7482C4 20.8876 4.08932 21 4.19843 21H7.07594C7.18515 21 7.27446 20.8876 7.27446 20.7482V17.1834C7.27446 17.1073 7.30136 17.0344 7.34815 16.987L9.94075 14.3486C10.0031 14.2853 10.0895 14.2757 10.159 14.3232L17.0934 19.5573C18.2289 20.3412 19.4975 20.8226 20.786 20.9652C20.9008 20.9778 21 20.8606 21 20.7133V17.3559C21 17.2276 20.9249 17.1232 20.8243 17.1073C20.0659 16.9853 19.326 16.6845 18.6569 16.222L12.6538 11.764C12.5291 11.6785 12.5135 11.4584 12.6241 11.346Z"
fill="currentColor"
@@ -1322,13 +773,7 @@ export default function Home() {
</div>
<A href="/zen">
<span>Learn about Zen </span>
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
stroke="currentColor"
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index cc44f8674..3260f31b2 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -13,146 +13,144 @@ export async function POST(input: APIEvent) {
input.request.headers.get("stripe-signature")!,
Resource.STRIPE_WEBHOOK_SECRET.value,
)
-
console.log(body.type, JSON.stringify(body, null, 2))
- if (body.type === "customer.updated") {
- // check default payment method changed
- const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
- if (!("default_payment_method" in prevInvoiceSettings)) return
- const customerID = body.data.object.id
- const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
+ return (async () => {
+ if (body.type === "customer.updated") {
+ // check default payment method changed
+ const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
+ if (!("default_payment_method" in prevInvoiceSettings)) return "ignored"
- if (!customerID) throw new Error("Customer ID not found")
- if (!paymentMethodID) throw new Error("Payment method ID not found")
+ const customerID = body.data.object.id
+ const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
- const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
- await Database.use(async (tx) => {
- await tx
- .update(BillingTable)
- .set({
- paymentMethodID,
- paymentMethodLast4: paymentMethod.card?.last4 ?? null,
- paymentMethodType: paymentMethod.type,
- })
- .where(eq(BillingTable.customerID, customerID))
- })
- }
- if (body.type === "checkout.session.completed") {
- const workspaceID = body.data.object.metadata?.workspaceID
- const customerID = body.data.object.customer as string
- const paymentID = body.data.object.payment_intent as string
- const invoiceID = body.data.object.invoice as string
- const amount = body.data.object.amount_total
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentMethodID) throw new Error("Payment method ID not found")
- if (!workspaceID) throw new Error("Workspace ID not found")
- if (!customerID) throw new Error("Customer ID not found")
- if (!amount) throw new Error("Amount not found")
- if (!paymentID) throw new Error("Payment ID not found")
- if (!invoiceID) throw new Error("Invoice ID not found")
-
- await Actor.provide("system", { workspaceID }, async () => {
- const customer = await Billing.get()
- if (customer?.customerID && customer.customerID !== customerID)
- throw new Error("Customer ID mismatch")
+ const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
+ await Database.use(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ paymentMethodID,
+ paymentMethodLast4: paymentMethod.card?.last4 ?? null,
+ paymentMethodType: paymentMethod.type,
+ })
+ .where(eq(BillingTable.customerID, customerID))
+ })
+ }
+ if (body.type === "checkout.session.completed") {
+ const workspaceID = body.data.object.metadata?.workspaceID
+ const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
+ const customerID = body.data.object.customer as string
+ const paymentID = body.data.object.payment_intent as string
+ const invoiceID = body.data.object.invoice as string
+
+ if (!workspaceID) throw new Error("Workspace ID not found")
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!amountInCents) throw new Error("Amount not found")
+ if (!paymentID) throw new Error("Payment ID not found")
+ if (!invoiceID) throw new Error("Invoice ID not found")
+
+ await Actor.provide("system", { workspaceID }, async () => {
+ const customer = await Billing.get()
+ if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // set customer metadata
+ if (!customer?.customerID) {
+ await Billing.stripe().customers.update(customerID, {
+ metadata: {
+ workspaceID,
+ },
+ })
+ }
- // set customer metadata
- if (!customer?.customerID) {
- await Billing.stripe().customers.update(customerID, {
- metadata: {
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
+ customerID,
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card?.last4 ?? null,
+ paymentMethodType: paymentMethod.type,
+ // enable reload if first time enabling billing
+ ...(customer?.customerID
+ ? {}
+ : {
+ reload: true,
+ reloadError: null,
+ timeReloadError: null,
+ }),
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ await tx.insert(PaymentTable).values({
workspaceID,
- },
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(amountInCents),
+ paymentID,
+ invoiceID,
+ customerID,
+ })
})
- }
-
- // get payment method for the payment intent
- const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
- expand: ["payment_method"],
})
- const paymentMethod = paymentIntent.payment_method
- if (!paymentMethod || typeof paymentMethod === "string")
- throw new Error("Payment method not expanded")
-
- const oldBillingInfo = await Database.use((tx) =>
+ }
+ if (body.type === "charge.refunded") {
+ const customerID = body.data.object.customer as string
+ const paymentIntentID = body.data.object.payment_intent as string
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentIntentID) throw new Error("Payment ID not found")
+
+ const workspaceID = await Database.use((tx) =>
tx
.select({
- customerID: BillingTable.customerID,
+ workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
- .where(eq(BillingTable.workspaceID, workspaceID))
- .then((rows) => rows[0]),
+ .where(eq(BillingTable.customerID, customerID))
+ .then((rows) => rows[0]?.workspaceID),
)
+ if (!workspaceID) throw new Error("Workspace ID not found")
+
+ const amount = await Database.use((tx) =>
+ tx
+ .select({
+ amount: PaymentTable.amount,
+ })
+ .from(PaymentTable)
+ .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
+ .then((rows) => rows[0]?.amount),
+ )
+ if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
+ .update(PaymentTable)
+ .set({
+ timeRefunded: new Date(body.created * 1000),
+ })
+ .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
+
+ await tx
.update(BillingTable)
.set({
- balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
- customerID,
- paymentMethodID: paymentMethod.id,
- paymentMethodLast4: paymentMethod.card?.last4 ?? null,
- paymentMethodType: paymentMethod.type,
- // enable reload if first time enabling billing
- ...(oldBillingInfo?.customerID
- ? {}
- : {
- reload: true,
- reloadError: null,
- timeReloadError: null,
- }),
+ balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
- await tx.insert(PaymentTable).values({
- workspaceID,
- id: Identifier.create("payment"),
- amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
- paymentID,
- invoiceID,
- customerID,
- })
})
+ }
+ })()
+ .then((message) => {
+ return Response.json({ message: message ?? "done" }, { status: 200 })
})
- }
- if (body.type === "charge.refunded") {
- const customerID = body.data.object.customer as string
- const paymentIntentID = body.data.object.payment_intent as string
- if (!customerID) throw new Error("Customer ID not found")
- if (!paymentIntentID) throw new Error("Payment ID not found")
-
- const workspaceID = await Database.use((tx) =>
- tx
- .select({
- workspaceID: BillingTable.workspaceID,
- })
- .from(BillingTable)
- .where(eq(BillingTable.customerID, customerID))
- .then((rows) => rows[0]?.workspaceID),
- )
- if (!workspaceID) throw new Error("Workspace ID not found")
-
- await Database.transaction(async (tx) => {
- await tx
- .update(PaymentTable)
- .set({
- timeRefunded: new Date(body.created * 1000),
- })
- .where(
- and(
- eq(PaymentTable.paymentID, paymentIntentID),
- eq(PaymentTable.workspaceID, workspaceID),
- ),
- )
-
- await tx
- .update(BillingTable)
- .set({
- balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
- })
- .where(eq(BillingTable.workspaceID, workspaceID))
+ .catch((error: any) => {
+ return Response.json({ message: error.message }, { status: 500 })
})
- }
-
- console.log("finished handling")
-
- return Response.json("ok", { status: 200 })
}
diff --git a/packages/console/app/src/routes/temp.tsx b/packages/console/app/src/routes/temp.tsx
index 59987e4d0..b0aef00e7 100644
--- a/packages/console/app/src/routes/temp.tsx
+++ b/packages/console/app/src/routes/temp.tsx
@@ -79,19 +79,17 @@ export default function Home() {
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
</li>
<li>
- <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a>{" "}
- provided by opencode <label>New</label>
+ <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
+ <label>New</label>
</li>
<li>
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
</li>
<li>
- <strong>Shareable links</strong> Share a link to any sessions for reference or to
- debug
+ <strong>Shareable links</strong> Share a link to any sessions for reference or to debug
</li>
<li>
- <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max
- account
+ <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
</li>
<li>
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
diff --git a/packages/console/app/src/routes/user-menu.css b/packages/console/app/src/routes/user-menu.css
index 15700579a..21008e98d 100644
--- a/packages/console/app/src/routes/user-menu.css
+++ b/packages/console/app/src/routes/user-menu.css
@@ -14,4 +14,4 @@
color: var(--color-danger);
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css
index ab7f5be66..dd0aafaf5 100644
--- a/packages/console/app/src/routes/workspace-picker.css
+++ b/packages/console/app/src/routes/workspace-picker.css
@@ -71,4 +71,4 @@
color: var(--color-text-muted);
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css
index e8f12796e..ddb13ab74 100644
--- a/packages/console/app/src/routes/workspace.css
+++ b/packages/console/app/src/routes/workspace.css
@@ -104,4 +104,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
index e0a80ef74..24b405391 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
@@ -71,6 +71,57 @@
flex: 1;
}
+ [data-slot="add-balance-form-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="add-balance-form"] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-3);
+
+ label {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ }
+
+ input[data-component="input"] {
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ }
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+
[data-slot="credit-card"] {
padding: var(--space-2) var(--space-4);
background-color: var(--color-bg-surface);
@@ -131,4 +182,4 @@
padding: var(--space-4);
min-width: 150px;
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
index c0723136b..fe4e08b7c 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
@@ -1,24 +1,86 @@
-import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
-import { createMemo, Match, Show, Switch } from "solid-js"
+import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
+import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
-import { createCheckoutUrl, queryBillingInfo } from "../../common"
+import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
- return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
+ return json(
+ await withActor(
+ () =>
+ Billing.generateSessionUrl({ returnUrl })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: queryBillingInfo.key },
+ )
}, "sessionUrl")
export function BillingSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
- const balanceInfo = createAsync(() => queryBillingInfo(params.id))
- const createCheckoutUrlAction = useAction(createCheckoutUrl)
- const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
- const createSessionUrlAction = useAction(createSessionUrl)
- const createSessionUrlSubmission = useSubmission(createSessionUrl)
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
+ const checkoutAction = useAction(createCheckoutUrl)
+ const checkoutSubmission = useSubmission(createCheckoutUrl)
+ const sessionAction = useAction(createSessionUrl)
+ const sessionSubmission = useSubmission(createSessionUrl)
+ const [store, setStore] = createStore({
+ showAddBalanceForm: false,
+ addBalanceAmount: billingInfo()?.reloadAmount.toString() ?? "",
+ checkoutRedirecting: false,
+ sessionRedirecting: false,
+ })
+
+ createEffect(() => {
+ const info = billingInfo()
+ if (info) {
+ setStore("addBalanceAmount", info.reloadAmount.toString())
+ }
+ })
+ const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
+
+ async function onClickCheckout() {
+ const amount = parseInt(store.addBalanceAmount)
+ const baseUrl = window.location.href
+
+ const checkout = await checkoutAction(params.id, amount, baseUrl, baseUrl)
+ if (checkout && checkout.data) {
+ setStore("checkoutRedirecting", true)
+ window.location.href = checkout.data
+ }
+ }
+
+ async function onClickSession() {
+ const baseUrl = window.location.href
+ const sessionUrl = await sessionAction(params.id, baseUrl)
+ if (sessionUrl && sessionUrl.data) {
+ setStore("sessionRedirecting", true)
+ window.location.href = sessionUrl.data
+ }
+ }
+
+ function showAddBalanceForm() {
+ while (true) {
+ checkoutSubmission.clear()
+ if (!checkoutSubmission.result) break
+ }
+ setStore({
+ showAddBalanceForm: true,
+ })
+ }
+
+ function hideAddBalanceForm() {
+ setStore("showAddBalanceForm", false)
+ checkoutSubmission.clear()
+ }
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
@@ -72,97 +134,104 @@ export function BillingSection() {
// timeReloadError: null as Date | null
// })
- const balanceAmount = createMemo(() => {
- return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
- })
-
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Billing</h2>
<p>
- Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any
- questions.
+ Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
</p>
</div>
<div data-slot="section-content">
<div data-slot="balance-display">
<div data-slot="balance-amount">
- <span data-slot="balance-value">
- ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
- </span>
+ <span data-slot="balance-value">${balance()}</span>
<span data-slot="balance-label">Current Balance</span>
</div>
- <Show when={balanceInfo()?.customerID}>
+ <Show when={billingInfo()?.customerID}>
<div data-slot="balance-right-section">
- <button
- data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
+ <Show
+ when={!store.showAddBalanceForm}
+ fallback={
+ <div data-slot="add-balance-form-container">
+ <div data-slot="add-balance-form">
+ <label>Add $</label>
+ <input
+ data-component="input"
+ type="number"
+ min={billingInfo()?.reloadAmountMin.toString()}
+ step="1"
+ value={store.addBalanceAmount}
+ onInput={(e) => {
+ setStore("addBalanceAmount", e.currentTarget.value)
+ checkoutSubmission.clear()
+ }}
+ placeholder="Enter amount"
+ />
+ <div data-slot="form-actions">
+ <button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
+ Cancel
+ </button>
+ <button
+ data-color="primary"
+ type="button"
+ disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
+ onClick={onClickCheckout}
+ >
+ {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"}
+ </button>
+ </div>
+ </div>
+ <Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
+ {(err: any) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ }
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
- </button>
+ <button data-color="primary" onClick={() => showAddBalanceForm()}>
+ Add Balance
+ </button>
+ </Show>
<div data-slot="credit-card">
<div data-slot="card-icon">
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <Match when={billingInfo()?.paymentMethodType === "link"}>
<IconStripe style={{ width: "24px", height: "24px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">
<Switch>
- <Match when={balanceInfo()?.paymentMethodType === "card"}>
- <Show
- when={balanceInfo()?.paymentMethodLast4}
- fallback={<span data-slot="number">----</span>}
- >
+ <Match when={billingInfo()?.paymentMethodType === "card"}>
+ <Show when={billingInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret">••••</span>
- <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ <span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
</Show>
</Match>
- <Match when={balanceInfo()?.paymentMethodType === "link"}>
+ <Match when={billingInfo()?.paymentMethodType === "link"}>
<span data-slot="type">Linked to Stripe</span>
</Match>
</Switch>
</div>
<button
data-color="ghost"
- disabled={createSessionUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
- if (sessionUrl) {
- window.location.href = sessionUrl
- }
- }}
+ disabled={sessionSubmission.pending || store.sessionRedirecting}
+ onClick={onClickSession}
>
- {createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
+ {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
</button>
</div>
</div>
</Show>
</div>
- <Show when={!balanceInfo()?.customerID}>
+ <Show when={!billingInfo()?.customerID}>
<button
data-slot="enable-billing-button"
data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
+ disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+ onClick={onClickCheckout}
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"}
</button>
</Show>
</div>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
index 4f0f8b2e6..a45bbf1a6 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
@@ -93,4 +93,4 @@
margin: 0;
line-height: 1.4;
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
index dbeda115c..77c017964 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
@@ -1,16 +1,10 @@
-import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
-
-const getBillingInfo = query(async (workspaceID: string) => {
- "use server"
- return withActor(async () => {
- return await Billing.get()
- }, workspaceID)
-}, "billing.get")
+import { queryBillingInfo } from "../../common"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
@@ -28,7 +22,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
- { revalidate: getBillingInfo.key },
+ { revalidate: queryBillingInfo.key },
)
}, "billing.setMonthlyLimit")
@@ -36,7 +30,7 @@ export function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
let input: HTMLInputElement
@@ -68,13 +62,13 @@ export function MonthlyLimitSection() {
<section class={styles.root}>
<div data-slot="section-title">
<h2>Monthly Limit</h2>
- <p>Set a monthly spending limit for your account.</p>
+ <p>Set a monthly usage limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
- {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
- <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+ {billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
+ <span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
@@ -106,15 +100,15 @@ export function MonthlyLimitSection() {
}
>
<button data-color="primary" onClick={() => show()}>
- {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ {billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
- <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
+ <Show when={billingInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No usage limit set.</p>}>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
- const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
+ const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
@@ -128,7 +122,7 @@ export function MonthlyLimitSection() {
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
- return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
+ return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css
index 08fb8524b..e37bad696 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css
@@ -34,6 +34,206 @@
}
}
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ margin-top: var(--space-4);
+
+ [data-slot="form-field"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ label {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="field-label"] {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="toggle-container"] {
+ display: flex;
+ align-items: center;
+ }
+
+ input[data-component="input"] {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+ }
+
+ [data-slot="input-row"] {
+ display: flex;
+ flex-direction: row;
+ gap: var(--space-3);
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+ }
+
+ [data-slot="input-field"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ flex: 1;
+
+ p {
+ line-height: 1.2;
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ }
+
+ input[data-component="input"] {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ min-width: 0;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: var(--color-bg-surface);
+ }
+ }
+
+ [data-slot="field-with-connector"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+
+ [data-slot="field-connector"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ }
+
+ input[data-component="input"] {
+ flex: 1;
+ min-width: 80px;
+ }
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ margin-top: var(--space-1);
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ margin-top: calc(var(--space-1) * -1);
+ }
+
+ [data-slot="model-toggle-label"] {
+ position: relative;
+ display: inline-block;
+ width: 2.5rem;
+ height: 1.5rem;
+ cursor: pointer;
+
+ input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ span {
+ position: absolute;
+ inset: 0;
+ background-color: #ccc;
+ border: 1px solid #bbb;
+ border-radius: 1.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 0.125rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: white;
+ border: 1px solid #ddd;
+ border-radius: 50%;
+ transform: translateY(-50%);
+ transition: all 0.3s ease;
+ }
+ }
+
+ input:checked + span {
+ background-color: #21ad0e;
+ border-color: #148605;
+
+ &::before {
+ transform: translateX(1rem) translateY(-50%);
+ }
+ }
+
+ &:hover span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+
+ input:checked:hover + span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
+ }
+
+ &:has(input:disabled) {
+ cursor: not-allowed;
+ }
+
+ input:disabled + span {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+
[data-slot="reload-error"] {
display: flex;
align-items: center;
@@ -54,7 +254,8 @@
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
+ padding: 0;
+ border: none;
}
}
}
-
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
index 6be6ddf31..8dcc4da92 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
@@ -1,17 +1,19 @@
-import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { Show } from "solid-js"
+import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, Show } from "solid-js"
+import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./reload-section.module.css"
+import { queryBillingInfo } from "../../common"
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.reload(), workspaceID), {
- revalidate: getBillingInfo.key,
+ revalidate: queryBillingInfo.key,
})
}, "billing.reload")
@@ -20,12 +22,27 @@ const setReload = action(async (form: FormData) => {
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const reloadValue = form.get("reload")?.toString() === "true"
+ const amountStr = form.get("reloadAmount")?.toString()
+ const triggerStr = form.get("reloadTrigger")?.toString()
+
+ const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
+ const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
+
+ if (reloadValue) {
+ if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
+ return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
+ if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
+ return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
+ }
+
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
+ ...(reloadAmount !== null ? { reloadAmount } : {}),
+ ...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
@@ -35,22 +52,43 @@ const setReload = action(async (form: FormData) => {
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
- { revalidate: getBillingInfo.key },
+ { revalidate: queryBillingInfo.key },
)
}, "billing.setReload")
-const getBillingInfo = query(async (workspaceID: string) => {
- "use server"
- return withActor(async () => {
- return await Billing.get()
- }, workspaceID)
-}, "billing.get")
-
export function ReloadSection() {
const params = useParams()
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
+ const [store, setStore] = createStore({
+ show: false,
+ reload: false,
+ reloadAmount: "",
+ reloadTrigger: "",
+ })
+
+ createEffect(() => {
+ if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
+ setStore("show", false)
+ }
+ })
+
+ function show() {
+ while (true) {
+ setReloadSubmission.clear()
+ if (!setReloadSubmission.result) break
+ }
+ const info = billingInfo()!
+ setStore("show", true)
+ setStore("reload", info.reload ? true : true)
+ setStore("reloadAmount", info.reloadAmount.toString())
+ setStore("reloadTrigger", info.reloadTrigger.toString())
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
return (
<section class={styles.root}>
@@ -58,44 +96,102 @@ export function ReloadSection() {
<h2>Auto Reload</h2>
<div data-slot="title-row">
<Show
- when={balanceInfo()?.reload}
+ when={billingInfo()?.reload}
fallback={
- <p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
+ <p>
+ Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
+ </p>
}
>
<p>
- We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
- <b>$5</b>.
+ Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
+ when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
- <form action={setReload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
- <button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
- <Show
- when={balanceInfo()?.reload}
- fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
- >
- {setReloadSubmission.pending ? "Disabling..." : "Disable"}
- </Show>
- </button>
- </form>
+ <button data-color="primary" type="button" onClick={() => show()}>
+ {billingInfo()?.reload ? "Edit" : "Enable"}
+ </button>
</div>
</div>
- <div data-slot="section-content">
- <Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
+ <Show when={store.show}>
+ <form action={setReload} method="post" data-slot="create-form">
+ <div data-slot="form-field">
+ <label>
+ <span data-slot="field-label">Enable Auto Reload</span>
+ <div data-slot="toggle-container">
+ <label data-slot="model-toggle-label">
+ <input
+ type="checkbox"
+ name="reload"
+ value="true"
+ checked={store.reload}
+ onChange={(e) => setStore("reload", e.currentTarget.checked)}
+ />
+ <span></span>
+ </label>
+ </div>
+ </label>
+ </div>
+
+ <div data-slot="input-row">
+ <div data-slot="input-field">
+ <p>Reload $</p>
+ <input
+ data-component="input"
+ name="reloadAmount"
+ type="number"
+ min={billingInfo()?.reloadAmountMin.toString()}
+ step="1"
+ value={store.reloadAmount}
+ onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
+ placeholder={billingInfo()?.reloadAmount.toString()}
+ disabled={!store.reload}
+ />
+ </div>
+ <div data-slot="input-field">
+ <p>When balance reaches $</p>
+ <input
+ data-component="input"
+ name="reloadTrigger"
+ type="number"
+ min={billingInfo()?.reloadTriggerMin.toString()}
+ step="1"
+ value={store.reloadTrigger}
+ onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
+ placeholder={billingInfo()?.reloadTrigger.toString()}
+ disabled={!store.reload}
+ />
+ </div>
+ </div>
+
+ <Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
+ {(err: any) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="button" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
+ {setReloadSubmission.pending ? "Saving..." : "Save"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ <Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
+ <div data-slot="section-content">
<div data-slot="reload-error">
<p>
Reload failed at{" "}
- {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
+ {billingInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
- . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
- method and try again.
+ . Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
+ again.
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
@@ -104,8 +200,8 @@ export function ReloadSection() {
</button>
</form>
</div>
- </Show>
- </div>
+ </div>
+ </Show>
</section>
)
}
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index 8f7678f21..45f67ca38 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -1,22 +1,32 @@
+import { Show, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { IconLogo } from "~/component/icon"
-import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
-import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
-import { Show, createMemo } from "solid-js"
+import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id))
const billingInfo = createAsync(() => queryBillingInfo(params.id))
- const createCheckoutUrlAction = useAction(createCheckoutUrl)
- const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
-
- const balanceAmount = createMemo(() => {
- return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ const checkoutAction = useAction(createCheckoutUrl)
+ const checkoutSubmission = useSubmission(createCheckoutUrl)
+ const [store, setStore] = createStore({
+ checkoutRedirecting: false,
})
+ const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
+
+ async function onClickCheckout() {
+ const baseUrl = window.location.href
+ const checkout = await checkoutAction(params.id, billingInfo()!.reloadAmount, baseUrl, baseUrl)
+ if (checkout && checkout.data) {
+ setStore("checkoutRedirecting", true)
+ window.location.href = checkout.data
+ }
+ }
return (
<div data-page="workspace-[id]">
@@ -38,21 +48,15 @@ export default function () {
<button
data-color="primary"
data-size="sm"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
+ disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+ onClick={onClickCheckout}
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
+ {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
</button>
}
>
<span data-slot="balance">
- Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ Current balance <b>${balance()}</b>
</span>
</Show>
</span>
diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
index 1066b7f09..5705549e3 100644
--- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
@@ -171,7 +171,6 @@
}
@media (max-width: 40rem) {
-
th,
td {
padding: var(--space-2) var(--space-3);
@@ -181,8 +180,7 @@
th {
&:nth-child(3)
- /* Date */
- {
+ /* Date */ {
display: none;
}
}
@@ -190,11 +188,10 @@
td {
&:nth-child(3)
- /* Date */
- {
+ /* Date */ {
display: none;
}
}
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css
index 29f55a977..7a64fd9c7 100644
--- a/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css
+++ b/packages/console/app/src/routes/workspace/[id]/members/role-dropdown.css
@@ -69,4 +69,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
index 964f7dacb..7a1980ebe 100644
--- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
@@ -5,15 +5,7 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
-import {
- IconAlibaba,
- IconAnthropic,
- IconMoonshotAI,
- IconOpenAI,
- IconStealth,
- IconXai,
- IconZai,
-} from "~/component/icon"
+import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
@@ -31,6 +23,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
return {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
+ .filter(([id, _model]) => !id.startsWith("an-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
@@ -75,8 +68,7 @@ export function ModelSection() {
<div data-slot="section-title">
<h2>Models</h2>
<p>
- Manage which models workspace members can access.{" "}
- <a href="/docs/zen#pricing ">Learn more</a>.
+ Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
</p>
</div>
<div data-slot="models-list">
diff --git a/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
index aaad823ab..bb58df79b 100644
--- a/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
@@ -140,4 +140,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.module.css b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css
index 1a450d3dc..1dc7085b7 100644
--- a/packages/console/app/src/routes/workspace/[id]/provider-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css
@@ -128,7 +128,6 @@
}
@media (max-width: 40rem) {
-
th,
td {
padding: var(--space-2) var(--space-3);
@@ -136,4 +135,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
index 6ec8477b4..5419ed7fb 100644
--- a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
@@ -22,7 +22,9 @@ const removeProvider = action(async (form: FormData) => {
if (!provider) return { error: "Provider is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
- return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key })
+ return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
+ revalidate: listProviders.key,
+ })
}, "provider.remove")
const saveProvider = action(async (form: FormData) => {
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
index 058fbe301..6764a0534 100644
--- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
@@ -31,7 +31,7 @@
margin: 0;
}
- >button {
+ > button {
align-self: flex-start;
}
}
@@ -80,7 +80,7 @@
}
}
- >button[type="reset"] {
+ > button[type="reset"] {
align-self: flex-start;
}
@@ -91,4 +91,4 @@
margin-top: calc(var(--space-1) * -1);
}
}
-} \ No newline at end of file
+}
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 69bfebe97..a6eaaeb1e 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -1,6 +1,6 @@
import { Resource } from "@opencode-ai/console-resource"
import { Actor } from "@opencode-ai/console-core/actor.js"
-import { action, query } from "@solidjs/router"
+import { action, json, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
@@ -34,6 +34,11 @@ export function formatDateUTC(date: Date) {
return date.toLocaleDateString("en-US", options)
}
+export function formatBalance(amount: number) {
+ const balance = ((amount ?? 0) / 100000000).toFixed(2)
+ return balance === "-0.00" ? "0.00" : balance
+}
+
export async function getLastSeenWorkspaceID() {
"use server"
return withActor(async () => {
@@ -62,23 +67,40 @@ export const querySessionInfo = query(async (workspaceID: string) => {
return withActor(() => {
return {
isAdmin: Actor.userRole() === "admin",
- isBeta:
- Resource.App.stage === "production"
- ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y"
- : true,
+ isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
}
}, workspaceID)
}, "session.get")
export const createCheckoutUrl = action(
- async (workspaceID: string, successUrl: string, cancelUrl: string) => {
+ async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
"use server"
- return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+ return json(
+ await withActor(
+ () =>
+ Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ )
},
"checkoutUrl",
)
export const queryBillingInfo = query(async (workspaceID: string) => {
"use server"
- return withActor(() => Billing.get(), workspaceID)
+ return withActor(async () => {
+ const billing = await Billing.get()
+ return {
+ ...billing,
+ reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
+ reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
+ reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
+ reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
+ }
+ }, workspaceID)
}, "billing.get")
diff --git a/packages/console/app/src/routes/zen/index.css b/packages/console/app/src/routes/zen/index.css
index 661517c43..fbdd15306 100644
--- a/packages/console/app/src/routes/zen/index.css
+++ b/packages/console/app/src/routes/zen/index.css
@@ -277,7 +277,7 @@ body {
margin-bottom: 24px;
}
- strong {
+ h1 {
font-size: 28px;
color: var(--color-text-strong);
font-weight: 500;
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx
index 08e58e160..4eab4dcb9 100644
--- a/packages/console/app/src/routes/zen/index.tsx
+++ b/packages/console/app/src/routes/zen/index.tsx
@@ -3,6 +3,7 @@ import { createAsync, query, redirect } from "@solidjs/router"
import { Title, Meta, Link } from "@solidjs/meta"
import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"
+import { config } from "~/config"
import zenLogoDark from "../../asset/zen-ornate-dark.svg"
import compareVideo from "../../asset/lander/opencode-comparison-min.mp4"
import compareVideoPoster from "../../asset/lander/opencode-comparison-poster.png"
@@ -30,6 +31,7 @@ export default function Home() {
<main data-page="zen">
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
+ <Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
@@ -42,7 +44,7 @@ export default function Home() {
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={zenLogoLight} alt="zen logo light" />
<img data-slot="zen logo dark" src={zenLogoDark} alt="zen logo dark" />
- <strong>Reliable optimized models for coding agents</strong>
+ <h1>Reliable optimized models for coding agents</h1>
<p>
Zen gives you access to a curated set of AI models that OpenCode has tested and benchmarked specifically
for coding agents. No need to worry about inconsistent performance and quality, use validated models
diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts
index dfc7e9fcd..f1e131468 100644
--- a/packages/console/app/src/routes/zen/util/error.ts
+++ b/packages/console/app/src/routes/zen/util/error.ts
@@ -3,3 +3,4 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
+export class RateLimitError extends Error {}
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index 0d46e8580..edaac3a7b 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -12,18 +12,14 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
-import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
-import {
- createBodyConverter,
- createStreamPartConverter,
- createResponseConverter,
-} from "./provider/provider"
+import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
+import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
+import { createRateLimiter } from "./rateLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
-type Model = ZenData["models"][string]
export async function handler(
input: APIEvent,
@@ -32,6 +28,10 @@ export async function handler(
parseApiKey: (headers: Headers) => string | undefined
},
) {
+ type AuthInfo = Awaited<ReturnType<typeof authenticate>>
+ type ModelInfo = Awaited<ReturnType<typeof validateModel>>
+ type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
+
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -39,6 +39,7 @@ export async function handler(
try {
const body = await input.request.json()
+ const ip = input.request.headers.get("x-real-ip") ?? ""
logger.metric({
is_tream: !!body.stream,
session: input.request.headers.get("x-opencode-session"),
@@ -46,13 +47,11 @@ export async function handler(
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
- const providerInfo = selectProvider(
- zenData,
- modelInfo,
- input.request.headers.get("x-real-ip") ?? "",
- )
+ const providerInfo = selectProvider(zenData, modelInfo, ip)
const authInfo = await authenticate(modelInfo, providerInfo)
- validateBilling(modelInfo, authInfo)
+ const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
+ await rateLimiter?.check()
+ validateBilling(authInfo, modelInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
@@ -67,7 +66,7 @@ export async function handler(
}),
)
logger.debug("REQUEST URL: " + reqUrl)
- logger.debug("REQUEST: " + reqBody)
+ logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetch(reqUrl, {
method: "POST",
headers: (() => {
@@ -92,9 +91,6 @@ export async function handler(
}
}
logger.debug("STATUS: " + res.status + " " + res.statusText)
- if (res.status === 400 || res.status === 503) {
- logger.debug("RESPONSE: " + (await res.text()))
- }
// Handle non-streaming response
if (!body.stream) {
@@ -103,6 +99,7 @@ export async function handler(
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body)
+ await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo)
return new Response(body, {
@@ -131,6 +128,7 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
+ await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
await trackUsage(authInfo, modelInfo, providerInfo, usage)
@@ -205,6 +203,15 @@ export async function handler(
{ status: 401 },
)
+ if (error instanceof RateLimitError)
+ return new Response(
+ JSON.stringify({
+ type: "error",
+ error: { type: error.constructor.name, message: error.message },
+ }),
+ { status: 429 },
+ )
+
return new Response(
JSON.stringify({
type: "error",
@@ -229,12 +236,8 @@ export async function handler(
return { id: modelId, ...modelData }
}
- function selectProvider(
- zenData: ZenData,
- model: Awaited<ReturnType<typeof validateModel>>,
- ip: string,
- ) {
- const providers = model.providers
+ function selectProvider(zenData: ZenData, modelInfo: ModelInfo, ip: string) {
+ const providers = modelInfo.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
@@ -247,26 +250,22 @@ export async function handler(
throw new ModelError(`Provider ${provider.id} not supported`)
}
- const format = zenData.providers[provider.id].format
-
return {
...provider,
...zenData.providers[provider.id],
- ...(format === "anthropic"
- ? anthropicHelper
- : format === "openai"
- ? openaiHelper
- : oaCompatHelper),
+ ...(() => {
+ const format = zenData.providers[provider.id].format
+ if (format === "anthropic") return anthropicHelper
+ if (format === "openai") return openaiHelper
+ return oaCompatHelper
+ })(),
}
}
- async function authenticate(
- model: Awaited<ReturnType<typeof validateModel>>,
- providerInfo: Awaited<ReturnType<typeof selectProvider>>,
- ) {
+ async function authenticate(modelInfo: ModelInfo, providerInfo: ProviderInfo) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) {
- if (model.allowAnonymous) return
+ if (modelInfo.allowAnonymous) return
throw new AuthError("Missing API key.")
}
@@ -281,6 +280,7 @@ export async function handler(
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
+ reloadTrigger: BillingTable.reloadTrigger,
},
user: {
id: UserTable.id,
@@ -296,20 +296,11 @@ export async function handler(
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
- .innerJoin(
- UserTable,
- and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
- )
- .leftJoin(
- ModelTable,
- and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
- )
+ .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
+ .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id)))
.leftJoin(
ProviderTable,
- and(
- eq(ProviderTable.workspaceID, KeyTable.workspaceID),
- eq(ProviderTable.provider, providerInfo.id),
- ),
+ and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
@@ -332,11 +323,11 @@ export async function handler(
}
}
- function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
+ function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
if (!authInfo) return
if (authInfo.provider?.credentials) return
if (authInfo.isFree) return
- if (model.allowAnonymous) return
+ if (modelInfo.allowAnonymous) return
const billing = authInfo.billing
if (!billing.paymentMethodID)
@@ -380,39 +371,24 @@ export async function handler(
}
}
- function validateModelSettings(authInfo: Awaited<ReturnType<typeof authenticate>>) {
+ function validateModelSettings(authInfo: AuthInfo) {
if (!authInfo) return
if (authInfo.isDisabled) throw new ModelError("Model is disabled")
}
- function updateProviderKey(
- authInfo: Awaited<ReturnType<typeof authenticate>>,
- providerInfo: Awaited<ReturnType<typeof selectProvider>>,
- ) {
+ function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
if (!authInfo) return
if (!authInfo.provider?.credentials) return
providerInfo.apiKey = authInfo.provider.credentials
}
- async function trackUsage(
- authInfo: Awaited<ReturnType<typeof authenticate>>,
- modelInfo: ReturnType<typeof validateModel>,
- providerInfo: Awaited<ReturnType<typeof selectProvider>>,
- usage: any,
- ) {
- const {
- inputTokens,
- outputTokens,
- reasoningTokens,
- cacheReadTokens,
- cacheWrite5mTokens,
- cacheWrite1hTokens,
- } = providerInfo.normalizeUsage(usage)
+ async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) {
+ const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
+ providerInfo.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
- inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
- 200_000
+ inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
? modelInfo.cost200K
: modelInfo.cost
@@ -463,8 +439,7 @@ export async function handler(
if (!authInfo) return
- const cost =
- authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
+ const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
@@ -504,9 +479,7 @@ export async function handler(
`,
timeMonthlyUsageUpdated: sql`now()`,
})
- .where(
- and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
- )
+ .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
})
await Database.use((tx) =>
@@ -517,7 +490,7 @@ export async function handler(
)
}
- async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
+ async function reload(authInfo: AuthInfo) {
if (!authInfo) return
if (authInfo.isFree) return
if (authInfo.provider?.credentials) return
@@ -532,11 +505,11 @@ export async function handler(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
- lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
- or(
- isNull(BillingTable.timeReloadLockedTill),
- lt(BillingTable.timeReloadLockedTill, sql`now()`),
+ lt(
+ BillingTable.balance,
+ centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
+ or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
index 603d8917b..d8d1cd741 100644
--- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts
+++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
@@ -98,7 +98,10 @@ export function fromAnthropicRequest(body: any): CommonRequest {
typeof (src as any).media_type === "string" &&
typeof (src as any).data === "string"
)
- return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
+ return {
+ type: "image_url",
+ image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` },
+ }
return undefined
}
@@ -165,7 +168,11 @@ export function fromAnthropicRequest(body: any): CommonRequest {
.filter((t: any) => t && typeof t === "object" && "input_schema" in t)
.map((t: any) => ({
type: "function",
- function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
+ function: {
+ name: (t as any).name,
+ description: (t as any).description,
+ parameters: (t as any).input_schema,
+ },
}))
: undefined
@@ -452,7 +459,12 @@ export function toAnthropicResponse(resp: CommonResponse) {
} catch {
input = (tc as any).function.arguments
}
- content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
+ content.push({
+ type: "tool_use",
+ id: (tc as any).id,
+ name: (tc as any).function.name,
+ input,
+ })
}
}
}
@@ -511,13 +523,22 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string {
if (json.type === "content_block_start") {
const cb = json.content_block
if (cb?.type === "text") {
- out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
+ out.choices.push({
+ index: json.index ?? 0,
+ delta: { role: "assistant", content: "" },
+ finish_reason: null,
+ })
} else if (cb?.type === "tool_use") {
out.choices.push({
index: json.index ?? 0,
delta: {
tool_calls: [
- { index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
+ {
+ index: json.index ?? 0,
+ id: cb.id,
+ type: "function",
+ function: { name: cb.name, arguments: "" },
+ },
],
},
finish_reason: null,
@@ -532,7 +553,9 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string {
} else if (d?.type === "input_json_delta") {
out.choices.push({
index: json.index ?? 0,
- delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
+ delta: {
+ tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }],
+ },
finish_reason: null,
})
}
diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
index daf650275..8a9170ef1 100644
--- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
+++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
@@ -532,7 +532,9 @@ export function toOaCompatibleChunk(chunk: CommonChunk): string {
total_tokens: chunk.usage.total_tokens,
...(chunk.usage.prompt_tokens_details?.cached_tokens
? {
- prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens },
+ prompt_tokens_details: {
+ cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens,
+ },
}
: {}),
}
diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts
index d17300991..e79e83579 100644
--- a/packages/console/app/src/routes/zen/util/provider/openai.ts
+++ b/packages/console/app/src/routes/zen/util/provider/openai.ts
@@ -77,7 +77,10 @@ export function fromOpenaiRequest(body: any): CommonRequest {
typeof (s as any).media_type === "string" &&
typeof (s as any).data === "string"
)
- return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
+ return {
+ type: "image_url",
+ image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` },
+ }
return undefined
}
@@ -153,7 +156,11 @@ export function fromOpenaiRequest(body: any): CommonRequest {
}
if ((m as any).role === "tool") {
- msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content })
+ msgs.push({
+ role: "tool",
+ tool_call_id: (m as any).tool_call_id,
+ content: (m as any).content,
+ })
continue
}
}
@@ -210,7 +217,10 @@ export function toOpenaiRequest(body: CommonRequest) {
typeof (s as any).media_type === "string" &&
typeof (s as any).data === "string"
)
- return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
+ return {
+ type: "input_image",
+ image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` },
+ }
return undefined
}
@@ -498,7 +508,9 @@ export function fromOpenaiChunk(chunk: string): CommonChunk | string {
if (typeof name === "string" && name.length > 0) {
out.choices.push({
index: 0,
- delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
+ delta: {
+ tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }],
+ },
finish_reason: null,
})
}
@@ -555,7 +567,12 @@ export function toOpenaiChunk(chunk: CommonChunk): string {
const model = chunk.model
if (d.content) {
- const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } }
+ const data = {
+ id,
+ type: "response.output_text.delta",
+ delta: d.content,
+ response: { id, model },
+ }
return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}`
}
@@ -565,7 +582,13 @@ export function toOpenaiChunk(chunk: CommonChunk): string {
const data = {
type: "response.output_item.added",
output_index: 0,
- item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" },
+ item: {
+ id: tc.id,
+ type: "function_call",
+ name: tc.function.name,
+ call_id: tc.id,
+ arguments: "",
+ },
}
return `event: response.output_item.added\ndata: ${JSON.stringify(data)}`
}
@@ -593,7 +616,11 @@ export function toOpenaiChunk(chunk: CommonChunk): string {
}
: undefined
- const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } }
+ const data: any = {
+ id,
+ type: "response.completed",
+ response: { id, model, ...(usage ? { usage } : {}) },
+ }
return `event: response.completed\ndata: ${JSON.stringify(data)}`
}
diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts
new file mode 100644
index 000000000..b3c036815
--- /dev/null
+++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts
@@ -0,0 +1,35 @@
+import { Resource } from "@opencode-ai/console-resource"
+import { RateLimitError } from "./error"
+import { logger } from "./logger"
+
+export function createRateLimiter(model: string, limit: number | undefined, ip: string) {
+ if (!limit) return
+
+ const now = Date.now()
+ const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}`
+ const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}`
+ let currRate: number
+ let prevRate: number
+
+ return {
+ track: async () => {
+ await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 })
+ },
+ check: async () => {
+ const values = await Resource.GatewayKv.get([currKey, prevKey])
+ const prevValue = values?.get(prevKey)
+ const currValue = values?.get(currKey)
+ prevRate = prevValue ? parseInt(prevValue) : 0
+ currRate = currValue ? parseInt(currValue) : 0
+ logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`)
+ if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
+ },
+ }
+}
+
+function buildYYYYMMDDHH(timestamp: number) {
+ return new Date(timestamp)
+ .toISOString()
+ .replace(/[^0-9]/g, "")
+ .substring(0, 10)
+}
diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts
index 3d0c31470..ee2b3ab54 100644
--- a/packages/console/app/src/routes/zen/v1/models.ts
+++ b/packages/console/app/src/routes/zen/v1/models.ts
@@ -50,10 +50,7 @@ export async function GET(input: APIEvent) {
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
- .leftJoin(
- ModelTable,
- and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)),
- )
+ .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows.map((row) => row.model)),
)