mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9953b345 |
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
|
||||
|
||||
@@ -8,11 +8,17 @@ const paths: Record<string, string | undefined> = {
|
||||
"POST /zen/v1/chat/completions": "/openai/v1/chat/completions",
|
||||
"POST /zen/v1/responses": "/openai/v1/responses",
|
||||
"POST /zen/v1/messages": "/anthropic/v1/messages",
|
||||
"POST /zen/go/v1/chat/completions": "/go/openai/v1/chat/completions",
|
||||
"POST /zen/go/v1/responses": "/go/openai/v1/responses",
|
||||
"POST /zen/go/v1/messages": "/go/anthropic/v1/messages",
|
||||
"GET /zen/v1/models": "/v1/models",
|
||||
"GET /zen/go/v1/models": "/go/v1/models",
|
||||
"GET /zen/go/v1/usage": "/go/v1/usage",
|
||||
}
|
||||
|
||||
export async function proxyInference(
|
||||
request: Request,
|
||||
generation: {
|
||||
generation?: {
|
||||
provider?: "openai" | "anthropic" | "google"
|
||||
/** The provider's native model ID, not the public Zen alias. */
|
||||
model?: string
|
||||
@@ -28,7 +34,8 @@ export async function proxyInference(
|
||||
: undefined)
|
||||
if (!path) return undefined
|
||||
|
||||
const key = path.startsWith("/anthropic/")
|
||||
const go = url.pathname.startsWith("/zen/go/")
|
||||
const key = url.pathname.endsWith("/messages")
|
||||
? request.headers.get("x-api-key")
|
||||
: path.startsWith("/google/")
|
||||
? request.headers.get("x-goog-api-key")
|
||||
@@ -36,38 +43,41 @@ export async function proxyInference(
|
||||
if (!key || key === "public") return undefined
|
||||
|
||||
// Routing only; the destination owns authentication and revocation after cutover.
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: WorkspaceTable.id,
|
||||
migratedAt: WorkspaceTable.migrated_at,
|
||||
provider: ProviderTable.provider,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.leftJoin(
|
||||
ProviderTable,
|
||||
generation.provider
|
||||
? and(
|
||||
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(ProviderTable.provider, generation.provider),
|
||||
isNull(ProviderTable.timeDeleted),
|
||||
sql`length(${ProviderTable.credentials}) > 0`,
|
||||
)
|
||||
: sql`false`,
|
||||
const native = go && /^oc_sk_[0-9a-f]{12}_[A-Za-z0-9_-]{32}$/.test(key)
|
||||
const workspace = native
|
||||
? undefined
|
||||
: await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: WorkspaceTable.id,
|
||||
migratedAt: WorkspaceTable.migrated_at,
|
||||
provider: ProviderTable.provider,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.leftJoin(
|
||||
ProviderTable,
|
||||
!go && generation?.provider
|
||||
? and(
|
||||
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(ProviderTable.provider, generation.provider),
|
||||
isNull(ProviderTable.timeDeleted),
|
||||
sql`length(${ProviderTable.credentials}) > 0`,
|
||||
)
|
||||
: sql`false`,
|
||||
)
|
||||
.where(eq(KeyTable.key, key))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
.where(eq(KeyTable.key, key))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!workspace?.migratedAt) return undefined
|
||||
const model = workspace.provider ? generation.model : undefined
|
||||
if (workspace.provider && !model) throw new Error("Legacy BYOK model mapping is unavailable")
|
||||
if (!native && !workspace?.migratedAt) return undefined
|
||||
const model = workspace?.provider ? generation?.model : undefined
|
||||
if (workspace?.provider && !model) throw new Error("Legacy BYOK model mapping is unavailable")
|
||||
|
||||
const destination = new URL(Resource.ConsoleMigration.inferenceUrl)
|
||||
// Imported connections must use this same workspace/provider-derived ID.
|
||||
const target = model
|
||||
? `/custom/conn_${workspace.id.slice(4)}_${workspace.provider}${
|
||||
? `/custom/conn_${workspace!.id.slice(4)}_${workspace!.provider}${
|
||||
path.startsWith("/google/")
|
||||
? `/models/${encodeURIComponent(model)}${url.pathname.slice(url.pathname.lastIndexOf(":"))}`
|
||||
: url.pathname.slice("/zen/v1".length)
|
||||
@@ -80,8 +90,19 @@ export async function proxyInference(
|
||||
// Model extraction has already read part of the body; forward its replay stream.
|
||||
const forwarded = new Request(
|
||||
destination,
|
||||
new Request(request, { method: request.method, body: generation.body(model) }),
|
||||
generation ? new Request(request, { method: request.method, body: generation.body(model) }) : request,
|
||||
)
|
||||
// Migrated requests use ordinary destination authentication and accounting.
|
||||
for (const name of [
|
||||
"x-zen",
|
||||
"x-zen-model",
|
||||
"x-zen-ip",
|
||||
"cf-access-client-id",
|
||||
"cf-access-client-secret",
|
||||
"host",
|
||||
"content-length",
|
||||
])
|
||||
forwarded.headers.delete(name)
|
||||
forwarded.headers.set("authorization", `Bearer ${key}`)
|
||||
const ip = request.headers.get("cf-connecting-ip")
|
||||
if (ip) forwarded.headers.set("x-real-ip", ip)
|
||||
@@ -90,3 +111,10 @@ export async function proxyInference(
|
||||
|
||||
return fetch(forwarded, { redirect: "manual" })
|
||||
}
|
||||
|
||||
export function inferenceUnavailable() {
|
||||
return Response.json(
|
||||
{ error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { buildModelsResponse, buildOptionsResponse } from "../../util/modelsHandler"
|
||||
import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy"
|
||||
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return buildOptionsResponse()
|
||||
}
|
||||
|
||||
export async function GET(_input: APIEvent) {
|
||||
export async function GET(input: APIEvent) {
|
||||
const response = await proxyInference(input.request).catch(inferenceUnavailable)
|
||||
if (response) return response
|
||||
const models = Object.keys(ZenData.list("lite").models)
|
||||
return buildModelsResponse(models)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ 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"
|
||||
import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const response = await proxyInference(input.request).catch(inferenceUnavailable)
|
||||
if (response) return response
|
||||
const apiKey = input.request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1]
|
||||
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -50,7 +50,7 @@ import { countryFromRequest, isModelCountryRestricted } from "~/lib/request-coun
|
||||
import { isPeakPricing } from "./pricing"
|
||||
import { prepareRequestBody } from "./requestBody"
|
||||
import { requiresGoTrainingConsent } from "./trainingConsent"
|
||||
import { proxyInference } from "~/lib/inference-proxy"
|
||||
import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type PreparedBody = Awaited<ReturnType<typeof prepareRequestBody>>
|
||||
@@ -102,22 +102,22 @@ export async function handler(
|
||||
const rawZenApiKey = opts.parseApiKey(input.request.headers)
|
||||
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
if (opts.modelList === "full" && model) {
|
||||
if (model) {
|
||||
// Read routing metadata without running legacy model, auth, or balance checks.
|
||||
const configured = zenData.models[model]
|
||||
const entry = Array.isArray(configured)
|
||||
? configured.find((entry) => entry.formatFilter === opts.format)
|
||||
: configured
|
||||
const response = await proxyInference(input.request, {
|
||||
provider: entry?.byokProvider,
|
||||
model: entry?.providers.find((provider) => provider.id === entry.byokProvider)?.model,
|
||||
provider: opts.modelList === "full" ? entry?.byokProvider : undefined,
|
||||
model:
|
||||
opts.modelList === "full"
|
||||
? entry?.providers.find((provider) => provider.id === entry.byokProvider)?.model
|
||||
: undefined,
|
||||
body: (providerModel) => requestBody?.stream(providerModel ?? model, false) ?? body,
|
||||
}).catch(() => {
|
||||
void (requestBody ? requestBody.cancel() : body.cancel()).catch(() => {})
|
||||
return Response.json(
|
||||
{ error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
return inferenceUnavailable()
|
||||
})
|
||||
if (response) return response
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy"
|
||||
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return buildOptionsResponse()
|
||||
@@ -14,12 +14,7 @@ export async function OPTIONS(_input: APIEvent) {
|
||||
export async function GET(input: APIEvent) {
|
||||
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
|
||||
if (apiKey && apiKey !== "public") {
|
||||
const response = await proxyModels(input, apiKey).catch(() =>
|
||||
Response.json(
|
||||
{ error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
||||
),
|
||||
)
|
||||
const response = await proxyInference(input.request).catch(inferenceUnavailable)
|
||||
if (response) return response
|
||||
}
|
||||
|
||||
@@ -45,26 +40,3 @@ export async function GET(input: APIEvent) {
|
||||
|
||||
return buildModelsResponse(models)
|
||||
}
|
||||
|
||||
async function proxyModels(input: APIEvent, apiKey: string) {
|
||||
// No legacy revocation or model-policy checks before destination authentication.
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ migratedAt: WorkspaceTable.migrated_at })
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.where(eq(KeyTable.key, apiKey))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!workspace?.migratedAt) return undefined
|
||||
|
||||
const destination = new URL(Resource.ConsoleMigration.inferenceUrl)
|
||||
destination.pathname = `${destination.pathname.replace(/\/$/, "")}/v1/models`
|
||||
destination.search = new URL(input.request.url).search
|
||||
destination.hash = ""
|
||||
const headers = new Headers({ authorization: `Bearer ${apiKey}` })
|
||||
const ip = input.request.headers.get("cf-connecting-ip")
|
||||
if (ip) headers.set("x-real-ip", ip)
|
||||
return fetch(destination, { headers, signal: input.request.signal, redirect: "manual" })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterAll, expect, mock, spyOn, test } from "bun:test"
|
||||
import { prepareRequestBody } from "../src/routes/zen/util/requestBody"
|
||||
|
||||
const requests: Array<{ path: string; headers: Headers; body: string }> = []
|
||||
let status = 200
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push({ path: url.pathname + url.search, headers: request.headers, body: await request.text() })
|
||||
return new Response("data: hello\n\ndata: [DONE]\n\n", {
|
||||
status,
|
||||
headers: { "content-type": "text/event-stream", "retry-after": "60" },
|
||||
})
|
||||
},
|
||||
})
|
||||
mock.module("@opencode-ai/console-resource", () => ({
|
||||
Resource: { ConsoleMigration: { inferenceUrl: `${server.url}inference` } },
|
||||
waitUntil: (promise: Promise<unknown>) => promise,
|
||||
}))
|
||||
const { Database } = await import("@opencode-ai/console-core/drizzle/index.js")
|
||||
const { proxyInference, inferenceUnavailable } = await import("../src/lib/inference-proxy")
|
||||
const lookup = spyOn(Database, "use")
|
||||
afterAll(() => {
|
||||
lookup.mockRestore()
|
||||
server.stop(true)
|
||||
})
|
||||
|
||||
test("migrated Go uses original credentials, replayed bodies and ordinary destination authentication", async () => {
|
||||
for (const [source, target, credential] of [
|
||||
["chat/completions", "openai/v1/chat/completions", "authorization"],
|
||||
["responses", "openai/v1/responses", "authorization"],
|
||||
["messages", "anthropic/v1/messages", "x-api-key"],
|
||||
] as const) {
|
||||
lookup.mockResolvedValue({ id: "wrk_migrated", migratedAt: new Date(), provider: null })
|
||||
const key = `sk-${"a".repeat(64)}`
|
||||
const request = new Request(`https://opencode.ai/zen/go/v1/${source}?trace=1`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[credential]: credential === "authorization" ? `Bearer ${key}` : key,
|
||||
"x-opencode-session": "session-go",
|
||||
"x-opencode-request": "request-go",
|
||||
"cf-connecting-ip": "192.0.2.5",
|
||||
"cf-ipcountry": "FR",
|
||||
"x-zen": "true",
|
||||
"x-zen-model": "override",
|
||||
"cf-access-client-secret": "must-not-forward",
|
||||
},
|
||||
body: '{"messages":[{"content":"large prompt"}],"model":"public-go","stream":true}',
|
||||
})
|
||||
const prepared = await prepareRequestBody(request.body!)
|
||||
const response = await proxyInference(request, { body: () => prepared.stream(prepared.model, false) })
|
||||
expect(response?.status).toBe(200)
|
||||
expect(await response?.text()).toBe("data: hello\n\ndata: [DONE]\n\n")
|
||||
const sent = requests.at(-1)!
|
||||
expect(sent.path).toBe(`/inference/go/${target}?trace=1`)
|
||||
expect(sent.headers.get("authorization")).toBe(`Bearer ${key}`)
|
||||
expect(sent.headers.get("x-opencode-session")).toBe("session-go")
|
||||
expect(sent.headers.get("x-opencode-request-id")).toBe("request-go")
|
||||
expect(sent.headers.get("x-real-ip")).toBe("192.0.2.5")
|
||||
expect(sent.headers.get("cf-ipcountry")).toBe("FR")
|
||||
expect(sent.headers.get("x-zen")).toBeNull()
|
||||
expect(sent.headers.get("x-zen-model")).toBeNull()
|
||||
expect(sent.headers.get("cf-access-client-secret")).toBeNull()
|
||||
expect(JSON.parse(sent.body)).toEqual({ messages: [{ content: "large prompt" }], model: "public-go", stream: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("native keys route generation and reads without a legacy database lookup", async () => {
|
||||
lookup.mockRejectedValue(new Error("Native keys must not query legacy storage"))
|
||||
const key = `oc_sk_${"a".repeat(12)}_${"-_".repeat(16)}`
|
||||
for (const operation of ["models", "usage", "messages"]) {
|
||||
const generation = operation === "messages"
|
||||
const request = new Request(`https://opencode.ai/zen/go/v1/${operation}`, {
|
||||
method: generation ? "POST" : "GET",
|
||||
headers: generation ? { "x-api-key": key } : { authorization: `Bearer ${key}` },
|
||||
...(generation ? { body: '{"model":"public-go"}' } : {}),
|
||||
})
|
||||
const response = await proxyInference(request, generation ? { body: () => request.body! } : undefined)
|
||||
expect(response?.status).toBe(200)
|
||||
await response?.body?.cancel()
|
||||
expect(requests.at(-1)?.path).toBe(`/inference/go/${generation ? "anthropic/v1/messages" : `v1/${operation}`}`)
|
||||
}
|
||||
})
|
||||
|
||||
test("unmigrated, unknown, missing and public keys keep legacy handling", async () => {
|
||||
const before = requests.length
|
||||
for (const workspace of [undefined, { id: "wrk_old", migratedAt: null, provider: null }]) {
|
||||
lookup.mockResolvedValue(workspace)
|
||||
for (const key of [undefined, "public", `sk-${"b".repeat(64)}`]) {
|
||||
const request = new Request("https://opencode.ai/zen/go/v1/usage", {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
})
|
||||
expect(await proxyInference(request)).toBeUndefined()
|
||||
}
|
||||
}
|
||||
expect(requests).toHaveLength(before)
|
||||
})
|
||||
|
||||
test("destination denial and routing failures never become legacy fallback", async () => {
|
||||
lookup.mockResolvedValue({ id: "wrk_migrated", migratedAt: new Date(), provider: null })
|
||||
const request = () =>
|
||||
new Request("https://opencode.ai/zen/go/v1/usage", { headers: { authorization: "Bearer old-key" } })
|
||||
for (const denied of [401, 403, 429, 503]) {
|
||||
status = denied
|
||||
const response = await proxyInference(request())
|
||||
expect(response?.status).toBe(denied)
|
||||
expect(response?.headers.get("retry-after")).toBe("60")
|
||||
await response?.body?.cancel()
|
||||
}
|
||||
status = 200
|
||||
lookup.mockRejectedValueOnce(new Error("database unavailable"))
|
||||
const response = await proxyInference(request()).catch(inferenceUnavailable)
|
||||
expect(response?.status).toBe(503)
|
||||
expect(response?.headers.get("cache-control")).toBe("no-store")
|
||||
})
|
||||
|
||||
test("Zen retains hosted and imported BYOK routing", async () => {
|
||||
for (const provider of [null, "openai"]) {
|
||||
lookup.mockResolvedValue({ id: "wrk_migrated", migratedAt: new Date(), provider })
|
||||
const request = new Request("https://opencode.ai/zen/v1/responses", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer zen-key" },
|
||||
body: '{"model":"zen-alias"}',
|
||||
})
|
||||
const prepared = await prepareRequestBody(request.body!)
|
||||
const response = await proxyInference(request, {
|
||||
provider: "openai",
|
||||
model: "native-model",
|
||||
body: (model) => prepared.stream(model ?? prepared.model, false),
|
||||
})
|
||||
await response?.body?.cancel()
|
||||
expect(requests.at(-1)?.path).toBe(
|
||||
provider ? "/inference/custom/conn_migrated_openai/responses" : "/inference/openai/v1/responses",
|
||||
)
|
||||
expect(JSON.parse(requests.at(-1)!.body).model).toBe(provider ? "native-model" : "zen-alias")
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user