Merge branch 'dev' into opentui
This commit is contained in:
commit
31a3c0082c
21 changed files with 708 additions and 286 deletions
|
|
@ -7,7 +7,7 @@
|
|||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.10.3"
|
||||
"version": "0.10.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
.root {
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="reload-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
p {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
[data-slot="payment"] {
|
||||
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);
|
||||
min-width: 14.5rem;
|
||||
width: fit-content;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="credit-card"] {
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
[data-slot="card-icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="card-details"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
|
||||
[data-slot="secret"] {
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="number"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button-row"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
flex-direction: column;
|
||||
|
||||
> button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Make Enable Billing button full width when it's the only button */
|
||||
> button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
[data-slot="usage"] {
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import styles from "./billing-section.module.css"
|
||||
import { Database, eq } from "@opencode/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
const updateShare = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const dataShare = form.get("dataShare")?.toString() === "true" ? true : null
|
||||
return json(
|
||||
await withActor(() => {
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.update(WorkspaceTable)
|
||||
.set({
|
||||
dataShare,
|
||||
})
|
||||
.where(eq(WorkspaceTable.id, workspaceID)),
|
||||
)
|
||||
}, workspaceID),
|
||||
{ revalidate: getWorkspaceInfo.key },
|
||||
)
|
||||
}, "workspace.disableShare")
|
||||
|
||||
const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => {
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
dataShare: WorkspaceTable.dataShare,
|
||||
})
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((r) => r[0]),
|
||||
)
|
||||
}, workspaceID)
|
||||
}, "workspace.get")
|
||||
|
||||
export function PrivacySection() {
|
||||
const params = useParams()
|
||||
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id))
|
||||
const updateShareSubmission = useSubmission(updateShare)
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Privacy controls</h2>
|
||||
<p>
|
||||
Some providers offer data-sharing programs. If you opt in, you voluntarily <b>share your usage data</b> with
|
||||
them, which they may use to improve their services, including <b>model training</b>.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
By opting in, you gain access to <b>discounted pricing</b> from the provider. You can opt in or out at any
|
||||
time.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<a target="_blank" href="/docs/zen">
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Show when={workspaceInfo()?.dataShare}>
|
||||
<div data-slot="payment">
|
||||
<div data-slot="credit-card">
|
||||
<div data-slot="card-details">
|
||||
<span data-slot="number">You are currently opted in to the data-sharing program.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="payment">
|
||||
<div data-slot="button-row">
|
||||
<form action={updateShare} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="dataShare" value={workspaceInfo()?.dataShare ? "false" : "true"} />
|
||||
<button data-color="ghost" type="submit" disabled={updateShareSubmission.pending}>
|
||||
{workspaceInfo()?.dataShare
|
||||
? updateShareSubmission.pending
|
||||
? "Opting out..."
|
||||
: "Opt out"
|
||||
: updateShareSubmission.pending
|
||||
? "Opting in..."
|
||||
: "Opt in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,15 +2,11 @@ import "./[id].css"
|
|||
import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
|
||||
import { NewUserSection } from "~/component/workspace/new-user-section"
|
||||
import { BillingSection } from "~/component/workspace/billing-section"
|
||||
import { PrivacySection } from "~/component/workspace/privacy-section"
|
||||
import { PaymentSection } from "~/component/workspace/payment-section"
|
||||
import { UsageSection } from "~/component/workspace/usage-section"
|
||||
import { KeySection } from "~/component/workspace/key-section"
|
||||
import { Show } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<section data-component="title-section">
|
||||
|
|
@ -29,9 +25,6 @@ export default function () {
|
|||
<KeySection />
|
||||
<BillingSection />
|
||||
<MonthlyLimitSection />
|
||||
<Show when={isBeta(params.id)}>
|
||||
<PrivacySection />
|
||||
</Show>
|
||||
<UsageSection />
|
||||
<PaymentSection />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export async function handler(
|
|||
const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
|
|
@ -52,7 +53,6 @@ export async function handler(
|
|||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
|
|
@ -85,10 +85,10 @@ export async function handler(
|
|||
session: input.request.headers.get("x-opencode-session"),
|
||||
request: input.request.headers.get("x-opencode-request"),
|
||||
})
|
||||
const authInfo = await authenticate()
|
||||
const modelInfo = validateModel(body.model, authInfo)
|
||||
const providerInfo = selectProvider(modelInfo, authInfo)
|
||||
if (authInfo && !providerInfo.allowAnonymous) validateBilling(authInfo)
|
||||
const modelInfo = validateModel(body.model)
|
||||
const providerInfo = selectProvider(modelInfo)
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
validateBilling(modelInfo, authInfo)
|
||||
logger.metric({ provider: providerInfo.id })
|
||||
|
||||
// Request to model provider
|
||||
|
|
@ -221,16 +221,41 @@ export async function handler(
|
|||
)
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
function validateModel(reqModel: string) {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
|
||||
const allModels = z.record(z.string(), ModelSchema).parse(json)
|
||||
|
||||
if (!(reqModel in allModels)) {
|
||||
throw new ModelError(`Model ${reqModel} not supported`)
|
||||
}
|
||||
const modelId = reqModel as keyof typeof allModels
|
||||
const modelData = allModels[modelId]
|
||||
|
||||
logger.metric({ model: modelId })
|
||||
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(model: Model) {
|
||||
const providers = model.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
|
||||
async function authenticate(model: Model) {
|
||||
const apiKey = opts.parseApiKey(input.request.headers)
|
||||
if (!apiKey) return
|
||||
if (!apiKey) {
|
||||
if (model.allowAnonymous) return
|
||||
throw new AuthError("Missing API key.")
|
||||
}
|
||||
|
||||
const data = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
apiKey: KeyTable.id,
|
||||
workspaceID: KeyTable.workspaceID,
|
||||
dataShare: WorkspaceTable.dataShare,
|
||||
balance: BillingTable.balance,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
monthlyLimit: BillingTable.monthlyLimit,
|
||||
|
|
@ -255,7 +280,6 @@ export async function handler(
|
|||
return {
|
||||
apiKeyId: data.apiKey,
|
||||
workspaceID: data.workspaceID,
|
||||
dataShare: data.dataShare,
|
||||
billing: {
|
||||
paymentMethodID: data.paymentMethodID,
|
||||
balance: data.balance,
|
||||
|
|
@ -267,8 +291,10 @@ export async function handler(
|
|||
}
|
||||
}
|
||||
|
||||
function validateBilling(authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
if (!authInfo || authInfo.isFree) return
|
||||
if (model.allowAnonymous) return
|
||||
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
|
|
@ -288,42 +314,6 @@ export async function handler(
|
|||
}
|
||||
}
|
||||
|
||||
function validateModel(reqModel: string, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
|
||||
const allModels = z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
standard: ModelSchema,
|
||||
dataShare: ModelSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.parse(json)
|
||||
|
||||
if (!(reqModel in allModels)) {
|
||||
throw new ModelError(`Model ${reqModel} not supported`)
|
||||
}
|
||||
const modelId = reqModel as keyof typeof allModels
|
||||
const modelData = authInfo?.dataShare
|
||||
? (allModels[modelId].dataShare ?? allModels[modelId].standard)
|
||||
: allModels[modelId].standard
|
||||
logger.metric({ model: modelId })
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
let providers = model.providers.filter((provider) => !provider.disabled)
|
||||
|
||||
if (!authInfo) {
|
||||
providers = providers.filter((provider) => provider.allowAnonymous)
|
||||
if (providers.length === 0) throw new AuthError("Missing API key.")
|
||||
}
|
||||
|
||||
const picks = providers.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
return picks[Math.floor(Math.random() * picks.length)]
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
authInfo: Awaited<ReturnType<typeof authenticate>>,
|
||||
modelInfo: ReturnType<typeof validateModel>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue