Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/console/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
"zen.api.error.missingApiKey": "Missing API key.",
"zen.api.error.invalidApiKey": "Invalid API key.",
"zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.",
"zen.api.error.requestBlockedByUpstreamProvider":
"This account has found to be committing fraud or is in breach of terms of services and has been blocked. If you believe this is a mistake please send an email to help@anoma.ly.",
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
Expand Down
158 changes: 158 additions & 0 deletions packages/console/app/src/routes/zen/go/v1/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"

export async function GET(input: APIEvent) {
const apiKey = input.request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1]

if (!apiKey) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "AuthError",
message: "Missing API key.",
},
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
},
)
}

const auth = await Database.use((tx) =>
tx
.select({
userID: KeyTable.userID,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.innerJoin(
UserTable,
and(
eq(UserTable.workspaceID, KeyTable.workspaceID),
eq(UserTable.id, KeyTable.userID),
isNull(UserTable.timeDeleted),
),
)
.innerJoin(WorkspaceTable, and(eq(WorkspaceTable.id, KeyTable.workspaceID), isNull(WorkspaceTable.timeDeleted)))
.innerJoin(
BillingTable,
and(eq(BillingTable.workspaceID, KeyTable.workspaceID), isNull(BillingTable.timeDeleted)),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)

if (!auth) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "AuthError",
message: "Unauthorized",
},
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
},
)
}

const row = await Database.use((tx) =>
tx
.select({
timeCreated: LiteTable.timeCreated,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
})
.from(LiteTable)
.where(
and(
eq(LiteTable.workspaceID, auth.workspaceID),
eq(LiteTable.userID, auth.userID),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0]),
)

if (!row) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "EntitlementError",
message: "OpenCode Go subscription required.",
},
}),
{
status: 403,
headers: {
"Content-Type": "application/json",
},
},
)
}

const limits = LiteData.getLimits()

return new Response(
JSON.stringify({
usage: {
rolling: formatUsage(
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
),
weekly: formatUsage(
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
),
monthly: formatUsage(
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
),
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
)
}

function formatUsage(usage: { status: "ok" | "rate-limited"; resetInSec: number; usagePercent: number }) {
return {
status: usage.status,
percent: usage.usagePercent,
resetsAt: new Date(Date.now() + usage.resetInSec * 1000).toISOString(),
}
}
5 changes: 4 additions & 1 deletion packages/opencode/src/plugin/github-copilot/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types?.includes("application/pdf") ?? false)

const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const endpoint: CopilotEndpoint | undefined = isMsgApi
Expand Down Expand Up @@ -127,7 +130,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
audio: false,
image,
video: false,
pdf: false,
pdf,
},
output: {
text: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ export function topP(model: Provider.Model) {
if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
return 0.95
}
if (
["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) ||
(id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode")))
) {
return 0.95
}
return undefined
}

Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/plugin/github-copilot-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,74 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})

test("detects PDF input support when vision and media type are advertised", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "pdf-model",
name: "PDF Model",
version: "pdf-model-2026-06-01",
capabilities: {
family: "pdf-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["application/pdf"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
{
model_picker_enabled: true,
id: "vision-only-model",
name: "Vision Only Model",
version: "vision-only-model-2026-06-01",
capabilities: {
family: "vision-only-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch

const models = (await CopilotModels.get("https://api.githubcopilot.com")).models
const model = models["pdf-model"]

expect(model.capabilities.input.pdf).toBe(true)
expect(models["vision-only-model"].capabilities.input.pdf).toBe(false)
})

test("uses zero cost when Copilot reports a zero billing batch size", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3272,6 +3272,37 @@ describe("ProviderTransform sampling defaults - Gemini", () => {
})
})

describe("ProviderTransform sampling defaults - DeepSeek", () => {
const model = (providerID: string, id: string) =>
({
id: `${providerID}/${id}`,
providerID,
api: { id },
}) as any

test.each([
["deepseek", "deepseek-v4-flash"],
["opencode", "deepseek-v4-flash"],
["opencode-go", "deepseek-v4-flash"],
["openrouter", "deepseek/deepseek-v4-flash-0731"],
["ollama-cloud", "deepseek-v4-flash:0731"],
])("defaults top_p for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBe(0.95)
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})

test.each([
["openrouter", "deepseek/deepseek-v4-flash"],
["vercel", "deepseek/deepseek-v4-flash"],
["custom", "deepseek-ai/DeepSeek-V4-Flash"],
])("preserves legacy defaults for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})
})

describe("ProviderTransform.reasoningVariants", () => {
const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
const target = (npm: string, id = "test-model") =>
Expand Down
Loading
Loading