Skip to content
Merged
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
50 changes: 50 additions & 0 deletions app/api/aiproxy/provision/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { provisionUserAiProxyApiKey } from '@/lib/aiproxy/api-key-provisioning'
import { getSessionFromReq } from '@/lib/session/server'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

function getFailureStatus(reason: string): number {
if (reason === 'missing_kubeconfig') {
return 400
}

if (reason === 'request_failed') {
return 502
}

return 500
}

export async function POST(req: NextRequest) {
try {
const session = await getSessionFromReq(req)

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const body = (await req.json().catch(() => ({}))) as { kubeconfig?: unknown }
const result = await provisionUserAiProxyApiKey({
kubeconfig: typeof body.kubeconfig === 'string' ? body.kubeconfig : null,
userId: session.user.id,
})

if (result.ok) {
return NextResponse.json({ success: true })
}

return NextResponse.json(
{
diagnostic: result.diagnostic,
error: 'Failed to provision AIProxy configuration',
reason: result.reason,
},
{ status: getFailureStatus(result.reason) },
)
} catch {
console.error('Failed to provision AIProxy configuration')
return NextResponse.json({ error: 'Failed to provision AIProxy configuration' }, { status: 500 })
}
}
8 changes: 8 additions & 0 deletions components/repo-commits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTasks } from '@/components/app-layout'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { RevertCommitDialog } from '@/components/revert-commit-dialog'
import { ensureAiProxyProvisioned } from '@/lib/aiproxy/client-provisioning'

function formatDistanceToNow(date: Date): string {
const now = new Date()
Expand Down Expand Up @@ -89,6 +90,13 @@ export function RepoCommits({ owner, repo }: RepoCommitsProps) {
keepAlive: boolean
}) => {
try {
const isAiProxyProvisioned = await ensureAiProxyProvisioned()

if (!isAiProxyProvisioned) {
toast.error('Failed to prepare AIProxy configuration')
return
}

const repoUrl = `https://github.com/${owner}/${repo}`
const commitShortSha = config.commit.sha.substring(0, 7)
const commitMessage = config.commit.commit.message.split('\n')[0]
Expand Down
8 changes: 8 additions & 0 deletions components/repo-issues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { User, Calendar, MessageSquare, MoreVertical, ListTodo } from 'lucide-react'
import { toast } from 'sonner'
import { ensureAiProxyProvisioned } from '@/lib/aiproxy/client-provisioning'
const FIXED_TASK_AGENT = 'codex'
const FIXED_TASK_MODEL = 'gpt-5.4'

Expand Down Expand Up @@ -117,6 +118,13 @@ export function RepoIssues({ owner, repo }: RepoIssuesProps) {

setIsCreatingTask(true)
try {
const isAiProxyProvisioned = await ensureAiProxyProvisioned()

if (!isAiProxyProvisioned) {
toast.error('Failed to prepare AIProxy configuration')
return
}

const repoUrl = `https://github.com/${owner}/${repo}`
const prompt = `Fix issue #${selectedIssue.number}: ${selectedIssue.title}${selectedIssue.body ? `\n\n${selectedIssue.body}` : ''}`

Expand Down
8 changes: 8 additions & 0 deletions components/repo-pull-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { GitPullRequest, Calendar, MessageSquare, MoreHorizontal, X, ListTodo } from 'lucide-react'
import { toast } from 'sonner'
import { ensureAiProxyProvisioned } from '@/lib/aiproxy/client-provisioning'
const FIXED_TASK_AGENT = 'codex'
const FIXED_TASK_MODEL = 'gpt-5.4'

Expand Down Expand Up @@ -136,6 +137,13 @@ export function RepoPullRequests({ owner, repo }: RepoPullRequestsProps) {

setIsCreatingTask(true)
try {
const isAiProxyProvisioned = await ensureAiProxyProvisioned()

if (!isAiProxyProvisioned) {
toast.error('Failed to prepare AIProxy configuration')
return
}

const repoUrl = `https://github.com/${owner}/${repo}`
const prompt = `Work on PR #${selectedPR.number}: ${selectedPR.title}${selectedPR.body ? `\n\n${selectedPR.body}` : ''}`

Expand Down
13 changes: 12 additions & 1 deletion components/sealos-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

import { useEffect } from 'react'
import { createSealosApp, sealosApp } from '@zjy365/sealos-desktop-sdk/app'
import {
ensureAiProxyProvisioned,
registerAiProxyKubeconfig,
registerAiProxyKubeconfigTask,
} from '@/lib/aiproxy/client-provisioning'
import { storeSealosKubeconfig } from '@/lib/sealos/storage'

export function SealosBootstrap() {
Expand All @@ -23,7 +28,10 @@ export function SealosBootstrap() {
}

try {
const sealosSession = await sealosApp.getSession()
const sealosSessionTask = sealosApp.getSession()
registerAiProxyKubeconfigTask(sealosSessionTask.then((session) => session.kubeconfig || null))

const sealosSession = await sealosSessionTask

if (!isActive) {
return
Expand All @@ -36,6 +44,9 @@ export function SealosBootstrap() {
return
}

registerAiProxyKubeconfig(sealosSession.kubeconfig)
void ensureAiProxyProvisioned()

if (storeSealosKubeconfig(sealosSession.kubeconfig)) {
console.info('Sealos kubeconfig stored')
} else {
Expand Down
17 changes: 13 additions & 4 deletions components/sealos-home-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { sessionAtom } from '@/lib/atoms/session'
import { githubConnectionAtom, githubConnectionInitializedAtom } from '@/lib/atoms/github-connection'
import type { Session } from '@/lib/session/types'
import { GitHubPopupAuthError, startGitHubPopupAuth } from '@/lib/auth/github-popup'
import { ensureAiProxyProvisioned } from '@/lib/aiproxy/client-provisioning'

interface SealosHomePageContentProps {
initialSelectedOwner?: string
Expand Down Expand Up @@ -99,13 +100,21 @@ export function SealosHomePageContent({
return
}

setTaskPrompt('')
setIsSubmitting(true)

const { id } = addTaskOptimistically(data)
router.push(`/tasks/${id}`)

try {
const isAiProxyProvisioned = await ensureAiProxyProvisioned()

if (!isAiProxyProvisioned) {
toast.error('Failed to prepare AIProxy configuration')
return
Comment on lines +106 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip mandatory AIProxy provisioning before task creation

This new early-return blocks task creation whenever ensureAiProxyProvisioned() fails, even though task execution can still succeed via the existing AI Gateway fallback path (resolveCodexGatewayFromApiKeys uses AI_GATEWAY_API_KEY when no AIProxy key is present). In environments where Sealos session/kubeconfig is unavailable (or temporarily fails), users now get a hard client-side stop instead of creating a valid task, so provisioning should be conditional rather than mandatory.

Useful? React with 👍 / 👎.

}

setTaskPrompt('')

const { id } = addTaskOptimistically(data)
router.push(`/tasks/${id}`)

const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
Expand Down
8 changes: 8 additions & 0 deletions components/task-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import PlaywrightIcon from '@/components/icons/playwright-icon'
import SupabaseIcon from '@/components/icons/supabase-icon'
import VercelIcon from '@/components/icons/vercel-icon'
import { PRStatusIcon } from '@/components/pr-status-icon'
import { ensureAiProxyProvisioned } from '@/lib/aiproxy/client-provisioning'

interface TaskDetailsProps {
task: Task
Expand Down Expand Up @@ -1169,6 +1170,13 @@ export function TaskDetails({ task, maxSandboxDuration = 300 }: TaskDetailsProps
const handleTryAgain = async () => {
setIsTryingAgain(true)
try {
const isAiProxyProvisioned = await ensureAiProxyProvisioned()

if (!isAiProxyProvisioned) {
toast.error('Failed to prepare AIProxy configuration')
return
}

const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
Expand Down
98 changes: 98 additions & 0 deletions lib/aiproxy/api-key-provisioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import 'server-only'

import { and, eq } from 'drizzle-orm'
import { AIPROXY_MODEL_BASE_URL } from '@/lib/aiproxy/constants'
import { getOrCreateAiProxyToken, type AiProxyTokenValidationIssue } from '@/lib/aiproxy/token-management'
import { encrypt } from '@/lib/crypto'
import { db } from '@/lib/db/client'
import { keys } from '@/lib/db/schema'
import { generateId } from '@/lib/utils/id'

export type AiProxyApiKeyProvisioningResult =
| {
mode: 'created' | 'existing'
ok: true
}
| {
diagnostic?: AiProxyTokenValidationIssue
ok: false
reason: 'missing_kubeconfig' | 'request_failed' | 'unexpected_response' | 'unusable_token'
}

async function findExistingAiProxyKey(userId: string) {
const [existing] = await db
.select({ id: keys.id })
.from(keys)
.where(and(eq(keys.userId, userId), eq(keys.provider, 'aiproxy')))
.limit(1)

return existing ?? null
}

export async function provisionUserAiProxyApiKey(input: {
kubeconfig?: string | null
userId: string
}): Promise<AiProxyApiKeyProvisioningResult> {
const existing = await findExistingAiProxyKey(input.userId)

if (existing) {
return {
mode: 'existing',
ok: true,
}
}

const kubeconfig = input.kubeconfig?.trim()

if (!kubeconfig) {
return {
ok: false,
reason: 'missing_kubeconfig',
}
}

const tokenResult = await getOrCreateAiProxyToken(kubeconfig)

if (!tokenResult.ok) {
return {
diagnostic: tokenResult.diagnostic,
ok: false,
reason: tokenResult.reason,
}
}

const insertResult = await db
.insert(keys)
.values({
baseUrl: AIPROXY_MODEL_BASE_URL,
id: generateId(21),
provider: 'aiproxy',
userId: input.userId,
value: encrypt(tokenResult.token.key),
})
.onConflictDoNothing({
target: [keys.userId, keys.provider],
})
.returning({ id: keys.id })

if (insertResult.length > 0) {
return {
mode: 'created',
ok: true,
}
}

const conflictingExisting = await findExistingAiProxyKey(input.userId)

if (conflictingExisting) {
return {
mode: 'existing',
ok: true,
}
}

return {
ok: false,
reason: 'request_failed',
}
}
79 changes: 79 additions & 0 deletions lib/aiproxy/client-provisioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client'

let aiProxyProvisioningTask: Promise<boolean> | null = null
let aiProxyKubeconfig: string | null = null
let aiProxyKubeconfigTask: Promise<string | null> | null = null

export function registerAiProxyKubeconfig(kubeconfig: string): void {
const normalizedKubeconfig = kubeconfig.trim()

if (normalizedKubeconfig) {
aiProxyKubeconfig = normalizedKubeconfig
}
}

export function registerAiProxyKubeconfigTask(task: Promise<string | null | undefined>): void {
aiProxyKubeconfigTask = task
.then((kubeconfig) => {
const normalizedKubeconfig = kubeconfig?.trim() || null

if (normalizedKubeconfig) {
aiProxyKubeconfig = normalizedKubeconfig
}

return normalizedKubeconfig
})
.catch(() => null)
}

export function getAiProxyProvisioningTask(runProvisioning: () => Promise<boolean>): Promise<boolean> {
if (!aiProxyProvisioningTask) {
aiProxyProvisioningTask = runProvisioning()
.catch(() => false)
.finally(() => {
aiProxyProvisioningTask = null
})
}

return aiProxyProvisioningTask
}

async function resolveAiProxyKubeconfig(): Promise<string | null> {
if (aiProxyKubeconfig) {
return aiProxyKubeconfig
}

if (!aiProxyKubeconfigTask) {
return null
}

return await aiProxyKubeconfigTask
}

async function requestAiProxyProvisioning(kubeconfig: string | null): Promise<boolean> {
const response = await fetch('/api/aiproxy/provision', {
body: JSON.stringify(kubeconfig ? { kubeconfig } : {}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})

if (!response.ok) {
return false
}

const body = (await response.json().catch(() => null)) as { success?: unknown } | null
return body?.success === true
}

export async function ensureAiProxyProvisioned(): Promise<boolean> {
const kubeconfig = await resolveAiProxyKubeconfig()
return await getAiProxyProvisioningTask(() => requestAiProxyProvisioning(kubeconfig))
}

export function resetAiProxyProvisioningTaskForTests(): void {
aiProxyProvisioningTask = null
aiProxyKubeconfig = null
aiProxyKubeconfigTask = null
}
3 changes: 3 additions & 0 deletions lib/aiproxy/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const AIPROXY_AUTO_TOKEN_NAME = 'shiprepo'
export const AIPROXY_MODEL_BASE_URL = 'https://aiproxy.usw-1.sealos.io/v1'
export const AIPROXY_TOKEN_MANAGEMENT_BASE_URL = 'https://aiproxy-web.usw-1.sealos.io/api/v2alpha'
Loading
Loading