mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(console): route migrated BYOK through provider connections (#47266)
This commit is contained in:
@@ -1,16 +1,24 @@
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||||
|
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
|
|
||||||
const paths: Record<string, string | undefined> = {
|
const paths: Record<string, string | undefined> = {
|
||||||
"GET /zen/v1/models": "/v1/models",
|
|
||||||
"POST /zen/v1/chat/completions": "/openai/v1/chat/completions",
|
"POST /zen/v1/chat/completions": "/openai/v1/chat/completions",
|
||||||
"POST /zen/v1/responses": "/openai/v1/responses",
|
"POST /zen/v1/responses": "/openai/v1/responses",
|
||||||
"POST /zen/v1/messages": "/anthropic/v1/messages",
|
"POST /zen/v1/messages": "/anthropic/v1/messages",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function proxyInference(request: Request, clientIP?: string): Promise<Response | undefined> {
|
export async function proxyInference(
|
||||||
|
request: Request,
|
||||||
|
generation: {
|
||||||
|
provider?: "openai" | "anthropic" | "google"
|
||||||
|
/** The provider's native model ID, not the public Zen alias. */
|
||||||
|
model?: string
|
||||||
|
body: (model?: string) => ReadableStream<Uint8Array>
|
||||||
|
},
|
||||||
|
): Promise<Response | undefined> {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const path =
|
const path =
|
||||||
paths[`${request.method} ${url.pathname}`] ??
|
paths[`${request.method} ${url.pathname}`] ??
|
||||||
@@ -30,23 +38,52 @@ export async function proxyInference(request: Request, clientIP?: string): Promi
|
|||||||
// Routing only; the destination owns authentication and revocation after cutover.
|
// Routing only; the destination owns authentication and revocation after cutover.
|
||||||
const workspace = await Database.use((tx) =>
|
const workspace = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({ migratedAt: WorkspaceTable.migrated_at })
|
.select({
|
||||||
|
id: WorkspaceTable.id,
|
||||||
|
migratedAt: WorkspaceTable.migrated_at,
|
||||||
|
provider: ProviderTable.provider,
|
||||||
|
})
|
||||||
.from(KeyTable)
|
.from(KeyTable)
|
||||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
.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`,
|
||||||
|
)
|
||||||
.where(eq(KeyTable.key, key))
|
.where(eq(KeyTable.key, key))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
if (!workspace?.migratedAt) return undefined
|
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")
|
||||||
|
|
||||||
const destination = new URL(Resource.ConsoleMigration.inferenceUrl)
|
const destination = new URL(Resource.ConsoleMigration.inferenceUrl)
|
||||||
destination.pathname = `${destination.pathname.replace(/\/$/, "")}${path}`
|
// Imported connections must use this same workspace/provider-derived ID.
|
||||||
|
const target = model
|
||||||
|
? `/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)
|
||||||
|
}`
|
||||||
|
: path
|
||||||
|
destination.pathname = `${destination.pathname.replace(/\/$/, "")}${target}`
|
||||||
destination.search = url.search
|
destination.search = url.search
|
||||||
destination.hash = ""
|
destination.hash = ""
|
||||||
|
|
||||||
const forwarded = new Request(destination, request)
|
// 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) }),
|
||||||
|
)
|
||||||
forwarded.headers.set("authorization", `Bearer ${key}`)
|
forwarded.headers.set("authorization", `Bearer ${key}`)
|
||||||
const ip = request.headers.get("cf-connecting-ip") ?? clientIP
|
const ip = request.headers.get("cf-connecting-ip")
|
||||||
if (ip) forwarded.headers.set("x-real-ip", ip)
|
if (ip) forwarded.headers.set("x-real-ip", ip)
|
||||||
const requestID = request.headers.get("x-opencode-request-id") ?? request.headers.get("x-opencode-request")
|
const requestID = request.headers.get("x-opencode-request-id") ?? request.headers.get("x-opencode-request")
|
||||||
if (requestID) forwarded.headers.set("x-opencode-request-id", requestID)
|
if (requestID) forwarded.headers.set("x-opencode-request-id", requestID)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createMiddleware } from "@solidjs/start/middleware"
|
|||||||
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
|
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
|
||||||
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
|
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
|
||||||
import { sanitizeServerActionRequest } from "~/lib/server-action"
|
import { sanitizeServerActionRequest } from "~/lib/server-action"
|
||||||
import { proxyInference } from "~/lib/inference-proxy"
|
|
||||||
|
|
||||||
export default createMiddleware({
|
export default createMiddleware({
|
||||||
async onRequest(event) {
|
async onRequest(event) {
|
||||||
@@ -20,12 +19,5 @@ export default createMiddleware({
|
|||||||
|
|
||||||
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
|
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
|
||||||
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
|
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
|
||||||
|
|
||||||
return proxyInference(event.request, event.clientAddress).catch(() =>
|
|
||||||
Response.json(
|
|
||||||
{ error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } },
|
|
||||||
{ status: 503, headers: { "Cache-Control": "no-store" } },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { countryFromRequest, isModelCountryRestricted } from "~/lib/request-coun
|
|||||||
import { isPeakPricing } from "./pricing"
|
import { isPeakPricing } from "./pricing"
|
||||||
import { prepareRequestBody } from "./requestBody"
|
import { prepareRequestBody } from "./requestBody"
|
||||||
import { requiresGoTrainingConsent } from "./trainingConsent"
|
import { requiresGoTrainingConsent } from "./trainingConsent"
|
||||||
|
import { proxyInference } from "~/lib/inference-proxy"
|
||||||
|
|
||||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||||
type PreparedBody = Awaited<ReturnType<typeof prepareRequestBody>>
|
type PreparedBody = Awaited<ReturnType<typeof prepareRequestBody>>
|
||||||
@@ -100,6 +101,26 @@ export async function handler(
|
|||||||
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||||
const rawZenApiKey = opts.parseApiKey(input.request.headers)
|
const rawZenApiKey = opts.parseApiKey(input.request.headers)
|
||||||
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
|
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
|
||||||
|
const zenData = ZenData.list(opts.modelList)
|
||||||
|
if (opts.modelList === "full" && 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,
|
||||||
|
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" } },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (response) return response
|
||||||
|
}
|
||||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||||
@@ -112,7 +133,6 @@ export async function handler(
|
|||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
"model.tier": opts.modelList === "full" ? "zen" : "go",
|
"model.tier": opts.modelList === "full" ? "zen" : "go",
|
||||||
})
|
})
|
||||||
const zenData = ZenData.list(opts.modelList)
|
|
||||||
const modelInfo = validateModel(zenData, model)
|
const modelInfo = validateModel(zenData, model)
|
||||||
const country = countryFromRequest(input.request)
|
const country = countryFromRequest(input.request)
|
||||||
if (isModelCountryRestricted(modelInfo.id, country)) throw new RegionError(t("zen.api.error.countryNotAllowed"))
|
if (isModelCountryRestricted(modelInfo.id, country)) throw new RegionError(t("zen.api.error.countryNotAllowed"))
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
|||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.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 { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||||
import { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler"
|
import { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler"
|
||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export async function OPTIONS(_input: APIEvent) {
|
export async function OPTIONS(_input: APIEvent) {
|
||||||
return buildOptionsResponse()
|
return buildOptionsResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(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" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (response) return response
|
||||||
|
}
|
||||||
|
|
||||||
const disabledModels = await (() => {
|
const disabledModels = await (() => {
|
||||||
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
|
|
||||||
if (!apiKey) return [] as string[]
|
if (!apiKey) return [] as string[]
|
||||||
|
|
||||||
return Database.use((tx) =>
|
return Database.use((tx) =>
|
||||||
@@ -34,3 +45,26 @@ export async function GET(input: APIEvent) {
|
|||||||
|
|
||||||
return buildModelsResponse(models)
|
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" })
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user