diff --git a/app/api/aiproxy/provision/route.ts b/app/api/aiproxy/provision/route.ts new file mode 100644 index 0000000..2c219e7 --- /dev/null +++ b/app/api/aiproxy/provision/route.ts @@ -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 }) + } +} diff --git a/components/repo-commits.tsx b/components/repo-commits.tsx index 67d67b5..6c84b52 100644 --- a/components/repo-commits.tsx +++ b/components/repo-commits.tsx @@ -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() @@ -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] diff --git a/components/repo-issues.tsx b/components/repo-issues.tsx index 07019a0..cd6dfa6 100644 --- a/components/repo-issues.tsx +++ b/components/repo-issues.tsx @@ -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' @@ -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}` : ''}` diff --git a/components/repo-pull-requests.tsx b/components/repo-pull-requests.tsx index 0a86ac2..78978fd 100644 --- a/components/repo-pull-requests.tsx +++ b/components/repo-pull-requests.tsx @@ -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' @@ -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}` : ''}` diff --git a/components/sealos-bootstrap.tsx b/components/sealos-bootstrap.tsx index a8406b3..dcee680 100644 --- a/components/sealos-bootstrap.tsx +++ b/components/sealos-bootstrap.tsx @@ -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() { @@ -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 @@ -36,6 +44,9 @@ export function SealosBootstrap() { return } + registerAiProxyKubeconfig(sealosSession.kubeconfig) + void ensureAiProxyProvisioned() + if (storeSealosKubeconfig(sealosSession.kubeconfig)) { console.info('Sealos kubeconfig stored') } else { diff --git a/components/sealos-home-page-content.tsx b/components/sealos-home-page-content.tsx index 250ea5b..7924279 100644 --- a/components/sealos-home-page-content.tsx +++ b/components/sealos-home-page-content.tsx @@ -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 @@ -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 + } + + setTaskPrompt('') + + const { id } = addTaskOptimistically(data) + router.push(`/tasks/${id}`) + const response = await fetch('/api/tasks', { method: 'POST', headers: { diff --git a/components/task-details.tsx b/components/task-details.tsx index 564cbca..a45925c 100644 --- a/components/task-details.tsx +++ b/components/task-details.tsx @@ -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 @@ -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: { diff --git a/lib/aiproxy/api-key-provisioning.ts b/lib/aiproxy/api-key-provisioning.ts new file mode 100644 index 0000000..96ff56a --- /dev/null +++ b/lib/aiproxy/api-key-provisioning.ts @@ -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 { + 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', + } +} diff --git a/lib/aiproxy/client-provisioning.ts b/lib/aiproxy/client-provisioning.ts new file mode 100644 index 0000000..fe6e8b4 --- /dev/null +++ b/lib/aiproxy/client-provisioning.ts @@ -0,0 +1,79 @@ +'use client' + +let aiProxyProvisioningTask: Promise | null = null +let aiProxyKubeconfig: string | null = null +let aiProxyKubeconfigTask: Promise | null = null + +export function registerAiProxyKubeconfig(kubeconfig: string): void { + const normalizedKubeconfig = kubeconfig.trim() + + if (normalizedKubeconfig) { + aiProxyKubeconfig = normalizedKubeconfig + } +} + +export function registerAiProxyKubeconfigTask(task: Promise): void { + aiProxyKubeconfigTask = task + .then((kubeconfig) => { + const normalizedKubeconfig = kubeconfig?.trim() || null + + if (normalizedKubeconfig) { + aiProxyKubeconfig = normalizedKubeconfig + } + + return normalizedKubeconfig + }) + .catch(() => null) +} + +export function getAiProxyProvisioningTask(runProvisioning: () => Promise): Promise { + if (!aiProxyProvisioningTask) { + aiProxyProvisioningTask = runProvisioning() + .catch(() => false) + .finally(() => { + aiProxyProvisioningTask = null + }) + } + + return aiProxyProvisioningTask +} + +async function resolveAiProxyKubeconfig(): Promise { + if (aiProxyKubeconfig) { + return aiProxyKubeconfig + } + + if (!aiProxyKubeconfigTask) { + return null + } + + return await aiProxyKubeconfigTask +} + +async function requestAiProxyProvisioning(kubeconfig: string | null): Promise { + 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 { + const kubeconfig = await resolveAiProxyKubeconfig() + return await getAiProxyProvisioningTask(() => requestAiProxyProvisioning(kubeconfig)) +} + +export function resetAiProxyProvisioningTaskForTests(): void { + aiProxyProvisioningTask = null + aiProxyKubeconfig = null + aiProxyKubeconfigTask = null +} diff --git a/lib/aiproxy/constants.ts b/lib/aiproxy/constants.ts new file mode 100644 index 0000000..14be8e2 --- /dev/null +++ b/lib/aiproxy/constants.ts @@ -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' diff --git a/lib/aiproxy/token-management.ts b/lib/aiproxy/token-management.ts new file mode 100644 index 0000000..6b26b8c --- /dev/null +++ b/lib/aiproxy/token-management.ts @@ -0,0 +1,157 @@ +import 'server-only' + +import { AIPROXY_AUTO_TOKEN_NAME, AIPROXY_TOKEN_MANAGEMENT_BASE_URL } from '@/lib/aiproxy/constants' +import { + diagnoseAiProxyTokenInfo, + isAiProxyTokenInfo, + isUsableAiProxyTokenInfo, + type AiProxyTokenInfo, + type AiProxyTokenValidationIssue, +} from '@/lib/aiproxy/token-validation' +export { + diagnoseAiProxyTokenInfo, + isAiProxyTokenInfo, + isUsableAiProxyTokenInfo, + type AiProxyTokenInfo, + type AiProxyTokenValidationIssue, +} from '@/lib/aiproxy/token-validation' + +export type AiProxyTokenProvisioningResult = + | { + ok: true + token: AiProxyTokenInfo + } + | { + ok: false + diagnostic?: AiProxyTokenValidationIssue + reason: 'missing_kubeconfig' | 'request_failed' | 'unexpected_response' | 'unusable_token' + status?: number + } + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, '') +} + +async function readJson(response: Response): Promise { + const text = await response.text() + + if (!text) { + return undefined + } + + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +async function requestAiProxyTokenManagement( + path: string, + kubeconfig: string, + init?: Omit & { headers?: Record }, +): Promise { + const baseUrl = trimTrailingSlash(AIPROXY_TOKEN_MANAGEMENT_BASE_URL) + + return await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + ...init?.headers, + Authorization: encodeURIComponent(kubeconfig), + }, + }) +} + +async function parseUsableToken(response: Response): Promise { + const payload = await readJson(response) + const diagnostic = diagnoseAiProxyTokenInfo(payload) + + if (!isAiProxyTokenInfo(payload)) { + return { + diagnostic: diagnostic ?? undefined, + ok: false, + reason: 'unexpected_response', + status: response.status, + } + } + + if (!isUsableAiProxyTokenInfo(payload)) { + return { + diagnostic: diagnostic ?? undefined, + ok: false, + reason: 'unusable_token', + status: response.status, + } + } + + return { + ok: true, + token: payload, + } +} + +export async function getOrCreateAiProxyToken(kubeconfig: string): Promise { + const normalizedKubeconfig = kubeconfig.trim() + + if (!normalizedKubeconfig) { + return { + ok: false, + reason: 'missing_kubeconfig', + } + } + + const tokenPath = `/tokens/${encodeURIComponent(AIPROXY_AUTO_TOKEN_NAME)}` + let lookupResponse: Response + + try { + lookupResponse = await requestAiProxyTokenManagement(tokenPath, normalizedKubeconfig, { + method: 'GET', + }) + } catch { + return { + ok: false, + reason: 'request_failed', + } + } + + if (lookupResponse.status === 200) { + return await parseUsableToken(lookupResponse) + } + + if (lookupResponse.status !== 404) { + return { + ok: false, + reason: 'request_failed', + status: lookupResponse.status, + } + } + + let createResponse: Response + + try { + createResponse = await requestAiProxyTokenManagement('/tokens', normalizedKubeconfig, { + body: JSON.stringify({ + name: AIPROXY_AUTO_TOKEN_NAME, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + } catch { + return { + ok: false, + reason: 'request_failed', + } + } + + if (createResponse.status !== 201) { + return { + ok: false, + reason: 'request_failed', + status: createResponse.status, + } + } + + return await parseUsableToken(createResponse) +} diff --git a/lib/aiproxy/token-validation.ts b/lib/aiproxy/token-validation.ts new file mode 100644 index 0000000..6d2c677 --- /dev/null +++ b/lib/aiproxy/token-validation.ts @@ -0,0 +1,112 @@ +import { AIPROXY_AUTO_TOKEN_NAME } from '@/lib/aiproxy/constants' + +export interface AiProxyTokenInfo { + id: number + key: string + name: string + status: number +} + +export type AiProxyTokenValidationIssue = + | 'not_object' + | 'missing_id' + | 'invalid_id' + | 'missing_name' + | 'invalid_name' + | 'wrong_name' + | 'missing_key' + | 'invalid_key' + | 'empty_key' + | 'masked_key' + | 'missing_status' + | 'invalid_status' + | 'disabled_token' + | 'unsupported_status' + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object') +} + +function hasOwnProperty(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + +export function isAiProxyTokenInfo(value: unknown): value is AiProxyTokenInfo { + if (!isRecord(value)) { + return false + } + + return ( + typeof value.id === 'number' && + typeof value.name === 'string' && + typeof value.key === 'string' && + typeof value.status === 'number' + ) +} + +export function diagnoseAiProxyTokenInfo( + value: unknown, + expectedName = AIPROXY_AUTO_TOKEN_NAME, +): AiProxyTokenValidationIssue | null { + if (!isRecord(value)) { + return 'not_object' + } + + if (!hasOwnProperty(value, 'id')) { + return 'missing_id' + } + + if (typeof value.id !== 'number') { + return 'invalid_id' + } + + if (!hasOwnProperty(value, 'name')) { + return 'missing_name' + } + + if (typeof value.name !== 'string') { + return 'invalid_name' + } + + if (!hasOwnProperty(value, 'key')) { + return 'missing_key' + } + + if (typeof value.key !== 'string') { + return 'invalid_key' + } + + if (!hasOwnProperty(value, 'status')) { + return 'missing_status' + } + + if (typeof value.status !== 'number') { + return 'invalid_status' + } + + if (value.name !== expectedName) { + return 'wrong_name' + } + + if (value.status === 2) { + return 'disabled_token' + } + + if (value.status !== 1) { + return 'unsupported_status' + } + + if (!value.key.trim()) { + return 'empty_key' + } + + if (value.key.includes('*')) { + return 'masked_key' + } + + return null +} + +export function isUsableAiProxyTokenInfo(token: AiProxyTokenInfo, expectedName = AIPROXY_AUTO_TOKEN_NAME): boolean { + return diagnoseAiProxyTokenInfo(token, expectedName) === null +} diff --git a/lib/api-keys/user-keys.ts b/lib/api-keys/user-keys.ts index fcf62d6..0efe0d7 100644 --- a/lib/api-keys/user-keys.ts +++ b/lib/api-keys/user-keys.ts @@ -5,13 +5,14 @@ import { keys } from '@/lib/db/schema' import { eq, and } from 'drizzle-orm' import { getServerSession } from '@/lib/session/get-server-session' import { decrypt } from '@/lib/crypto' +import { AIPROXY_MODEL_BASE_URL } from '@/lib/aiproxy/constants' export type Provider = 'openai' | 'gemini' | 'cursor' | 'anthropic' | 'aigateway' | 'aiproxy' export type GatewayProvider = 'aigateway' | 'aiproxy' export const GATEWAY_BASE_URLS: Record = { aigateway: 'https://ai-gateway.vercel.sh', - aiproxy: 'https://aiproxy.usw-1.sealos.io', + aiproxy: AIPROXY_MODEL_BASE_URL, } export const GATEWAY_ENV_KEYS: Record = { diff --git a/reference/aiproxy-token-management-api.json b/reference/aiproxy-token-management-api.json new file mode 100644 index 0000000..4869b23 --- /dev/null +++ b/reference/aiproxy-token-management-api.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"AIProxy Token Management API","version":"2.0.0-alpha","description":"AIProxy is the Sealos AI gateway. This API lets you manage API tokens that authenticate calls to AI models.\n\n## Authentication\n\nAll endpoints require one of two credential types in the `Authorization` header:\n\n- **Sealos App Token** (a Sealos-issued signed token): `Authorization: Bearer `\n- **Kubeconfig** (URL-encoded YAML): `Authorization: `\n\nObtain your App Token or kubeconfig from the Sealos console.\n\n## Errors\n\nAll error responses follow a unified format:\n\n```json\n{\n \"error\": {\n \"type\": \"validation_error\",\n \"code\": \"INVALID_PARAMETER\",\n \"message\": \"Token name is required.\",\n \"details\": [{ \"field\": \"name\", \"message\": \"Required\" }]\n }\n}\n```\n\n- `type` — high-level category (e.g. `validation_error`, `resource_error`, `internal_error`)\n- `code` — stable identifier for programmatic handling\n- `message` — human-readable explanation\n- `details` — optional extra context; shape varies by `code` (field list, string, or object)\n\n## Operations\n\n**Query** (read-only): returns `200 OK` with data in the response body.\n\n**Mutation** (write): Create → `201 Created` with the created resource. Update/Delete → `204 No Content`."},"servers":[{"url":"http://localhost:3000/api/v2alpha","description":"Local development"},{"url":"/api/v2alpha","description":"Production (same origin)"},{"url":"{baseUrl}/api/v2alpha","description":"Custom","variables":{"baseUrl":{"default":"https://aiproxy.example.com","description":"Base URL of your instance (e.g. https://aiproxy.192.168.x.x.nip.io)"}}}],"tags":[{"name":"Query","description":"Read-only operations. Success: `200 OK` with data in the response body."},{"name":"Mutation","description":"Write operations. Create: `201 Created` with the new resource. Update/Delete: `204 No Content`."}],"paths":{"/tokens":{"get":{"tags":["Query"],"summary":"List tokens","description":"Lists tokens with optional partial-name filtering and pagination.\n\nKey points:\n- Default: returns the first 10 tokens (page 1)\n- `name` parameter performs a **partial (fuzzy) match**; may return multiple results\n- For an exact name lookup, use `GET /tokens/{name}`","operationId":"listTokens","parameters":[{"name":"name","in":"query","required":false,"description":"Filter tokens by name (partial/fuzzy match). Returns all tokens whose name contains this string. For an exact lookup, use `GET /tokens/{name}`.","schema":{"type":"string"},"example":"my-api"},{"name":"page","in":"query","required":false,"description":"Page number (default: 1)","schema":{"type":"integer","minimum":1,"default":1},"example":1},{"name":"perPage","in":"query","required":false,"description":"Number of items per page (default: 10, max: 100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":10},"example":10}],"responses":{"200":{"description":"Tokens retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenListResponse"},"examples":{"default_list":{"summary":"Default list (first 10 tokens)","value":{"tokens":[{"id":123,"name":"production-token","key":"sk-****abc123","group":"ns-admin","subnet":"0.0.0.0/0","models":["gpt-4","gpt-3.5-turbo"],"status":1,"quota":1000,"used_amount":250.5,"request_count":500,"created_at":1697001600,"accessed_at":1697088000,"expired_at":-1}],"total":1}},"filtered_by_name":{"summary":"Filter by name","value":{"tokens":[{"id":123,"name":"my-api-token","key":"sk-****abc123","group":"ns-admin","subnet":"0.0.0.0/0","models":null,"status":1,"quota":500,"used_amount":100,"request_count":200,"created_at":1697001600,"accessed_at":1697088000,"expired_at":-1}],"total":1}},"empty_list":{"summary":"No tokens found","value":{"tokens":[],"total":0}}}}}},"400":{"description":"Bad request - invalid query parameters","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"validation_error":{"summary":"Invalid query parameters (page, perPage)","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid query parameters.","details":[{"field":"page","message":"Page must be at least 1"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to list tokens.","details":"Backend auth key is not configured"}}}}}}}}},"post":{"tags":["Mutation"],"summary":"Create a new token","description":"Creates a new API token for accessing AI services.\n\nAlways returns `201`. If a token with the given name already exists it is returned as-is rather than producing an error.","operationId":"createToken","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Token name (must be unique within the group)","minLength":1,"maxLength":100,"example":"my-api-token"}}},"examples":{"basic":{"summary":"Basic token creation","value":{"name":"production-token"}}}}}},"responses":{"201":{"description":"Token created or returned as-is if it already existed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInfo"},"examples":{"created":{"summary":"Token created","value":{"id":124,"name":"production-token","key":"sk-abcdefgh1234567890","group":"ns-admin","subnet":"0.0.0.0/0","models":null,"status":1,"quota":0,"used_amount":0,"request_count":0,"created_at":1697001600,"accessed_at":1697001600,"expired_at":-1}}}}}},"400":{"description":"Bad request - validation failed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"invalid_json":{"summary":"Invalid JSON body","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Request body must be valid JSON."}}},"validation_error":{"summary":"Validation error (name required/format)","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid request body.","details":[{"field":"name","message":"Token name is required"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to create token.","details":"Backend service URL is not configured"}}}}}}}}}},"/tokens/{name}":{"get":{"tags":["Query"],"summary":"Get a token by name","description":"Retrieves a single token by its exact name.\n\nKey points:\n- Matching is **exact and case-sensitive**\n- For a partial-name search, use `GET /tokens?name=...`","operationId":"getToken","parameters":[{"name":"name","in":"path","required":true,"description":"Exact token name to retrieve (case-sensitive, 1–100 characters).","schema":{"type":"string","minLength":1,"maxLength":100,"example":"my-api-token"}}],"responses":{"200":{"description":"Token retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInfo"},"examples":{"token":{"summary":"Token details","value":{"id":123,"name":"my-api-token","key":"sk-****abc123","group":"ns-admin","subnet":"0.0.0.0/0","models":["gpt-4","gpt-3.5-turbo"],"status":1,"quota":1000,"used_amount":250.5,"request_count":500,"created_at":1697001600,"accessed_at":1697088000,"expired_at":-1}}}}}},"400":{"description":"Bad request - invalid token name","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"validation_error":{"summary":"Token name fails validation","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid token name.","details":[{"field":"name","message":"Token name is required"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"404":{"description":"Token not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["resource_error"]},"code":{"type":"string","enum":["NOT_FOUND"]},"message":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"not_found":{"summary":"No token with this exact name exists","value":{"error":{"type":"resource_error","code":"NOT_FOUND","message":"The specified token does not exist."}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to get token.","details":"HTTP error! status: 502"}}}}}}}}},"delete":{"tags":["Mutation"],"summary":"Delete a token by name","description":"Permanently deletes a token by its exact name.\n\nKey points:\n- Matching is **exact and case-sensitive**\n- Returns `204` even if the token does not exist (idempotent)\n- **Irreversible**","operationId":"deleteToken","parameters":[{"name":"name","in":"path","required":true,"description":"Exact token name to delete (case-sensitive, 1–100 characters).","schema":{"type":"string","minLength":1,"maxLength":100,"example":"my-api-token"}}],"responses":{"204":{"description":"Token deleted successfully (no content)"},"400":{"description":"Bad request - invalid token name","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"validation_error":{"summary":"Invalid token name","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid token name.","details":[{"field":"name","message":"Token name is required"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to delete token.","details":"HTTP error! status: 502"}}}}}}}}}},"/tokens/{name}/enable":{"post":{"tags":["Mutation"],"summary":"Enable a token","description":"Enables an API token by its exact name. Enabled tokens (status=1) can be used to call AI services.\n\nKey points:\n- Matching is **exact and case-sensitive**\n- **Idempotent**: if the token is already enabled, returns `204` without calling the backend\n- State change takes effect immediately for subsequent API calls\n- Returns `404` if no token with this exact name exists","operationId":"enableToken","parameters":[{"name":"name","in":"path","required":true,"description":"Exact token name to enable (case-sensitive, 1–100 characters).","schema":{"type":"string","minLength":1,"maxLength":100,"example":"my-api-token"}}],"responses":{"204":{"description":"Token enabled successfully, or already enabled (no content)."},"400":{"description":"Bad request - invalid token name","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"validation_error":{"summary":"Token name fails validation","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid token name.","details":[{"field":"name","message":"Token name is required"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"404":{"description":"Token not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["resource_error"]},"code":{"type":"string","enum":["NOT_FOUND"]},"message":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"not_found":{"summary":"No token with this exact name exists","value":{"error":{"type":"resource_error","code":"NOT_FOUND","message":"The specified token does not exist."}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to enable token.","details":"HTTP error! status: 502"}}}}}}}}}},"/tokens/{name}/disable":{"post":{"tags":["Mutation"],"summary":"Disable a token","description":"Disables an API token by its exact name. Disabled tokens (status=2) are rejected when used to call AI services, but their history and quota records are preserved.\n\nKey points:\n- Matching is **exact and case-sensitive**\n- **Idempotent**: if the token is already disabled, returns `204` without calling the backend\n- State change takes effect immediately — in-flight requests using this token will fail\n- Returns `404` if no token with this exact name exists\n- Disabled tokens still appear in `GET /tokens` listings (with `status: 2`); use `DELETE /tokens/{name}` for permanent removal","operationId":"disableToken","parameters":[{"name":"name","in":"path","required":true,"description":"Exact token name to disable (case-sensitive, 1–100 characters).","schema":{"type":"string","minLength":1,"maxLength":100,"example":"my-api-token"}}],"responses":{"204":{"description":"Token disabled successfully, or already disabled (no content)."},"400":{"description":"Bad request - invalid token name","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["validation_error"]},"code":{"type":"string","enum":["INVALID_PARAMETER"]},"message":{"type":"string"},"details":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"],"additionalProperties":false}},{"type":"string"}]}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"validation_error":{"summary":"Token name fails validation","value":{"error":{"type":"validation_error","code":"INVALID_PARAMETER","message":"Invalid token name.","details":[{"field":"name","message":"Token name is required"}]}}}}}}},"401":{"description":"Unauthorized - invalid or missing authentication","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["authentication_error"]},"code":{"type":"string","enum":["AUTHENTICATION_REQUIRED"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"unauthorized":{"summary":"Authentication failed","value":{"error":{"type":"authentication_error","code":"AUTHENTICATION_REQUIRED","message":"Unauthorized, please login again.","details":"Auth: Token is missing"}}}}}}},"404":{"description":"Token not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["resource_error"]},"code":{"type":"string","enum":["NOT_FOUND"]},"message":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"not_found":{"summary":"No token with this exact name exists","value":{"error":{"type":"resource_error","code":"NOT_FOUND","message":"The specified token does not exist."}}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string","enum":["internal_error"]},"code":{"type":"string","enum":["INTERNAL_ERROR"]},"message":{"type":"string"},"details":{"type":"string"}},"required":["type","code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"examples":{"server_error":{"summary":"Server error","value":{"error":{"type":"internal_error","code":"INTERNAL_ERROR","message":"Failed to disable token.","details":"HTTP error! status: 502"}}}}}}}}}}},"security":[{"sealosAppToken":[]},{"kubeconfigAuth":[]}],"components":{"securitySchemes":{"sealosAppToken":{"type":"http","scheme":"bearer","description":"Sealos-issued App Token (a signed token specific to the Sealos platform). Obtain from the Sealos console. Header: `Authorization: Bearer `"},"kubeconfigAuth":{"type":"apiKey","in":"header","name":"Authorization","description":"URL-encoded kubeconfig YAML. Header: `Authorization: `"}},"schemas":{"TokenInfo":{"type":"object","description":"Token information","properties":{"id":{"type":"integer","description":"Unique token ID","example":123},"name":{"type":"string","description":"Token name","example":"my-api-token"},"key":{"type":"string","description":"API key.","example":"sk-****abc123"},"group":{"type":"string","description":"User group the token belongs to","example":"ns-admin"},"subnet":{"type":"string","description":"Allowed subnet for API access","example":"0.0.0.0/0"},"models":{"type":["array","null"],"description":"List of allowed AI models (null means all models are allowed)","items":{"type":"string"},"example":["gpt-4","gpt-3.5-turbo"]},"status":{"type":"integer","description":"Token status (1=enabled, 2=disabled)","enum":[1,2],"example":1},"quota":{"type":"number","format":"float","description":"Total quota allocated to the token","example":1000},"used_amount":{"type":"number","format":"float","description":"Amount of quota already used","example":250.5},"request_count":{"type":"integer","description":"Total number of API requests made","example":500},"created_at":{"type":"integer","format":"int64","description":"Creation timestamp (Unix epoch in seconds)","example":1697001600},"accessed_at":{"type":"integer","format":"int64","description":"Last access timestamp (Unix epoch in seconds)","example":1697088000},"expired_at":{"type":"integer","format":"int64","description":"Expiration timestamp (Unix epoch in seconds, -1 means never expires)","example":-1}},"required":["id","name","key","group","subnet","status","quota","used_amount","request_count","created_at","accessed_at","expired_at"]},"TokenListResponse":{"type":"object","description":"Token list response","properties":{"tokens":{"type":"array","description":"List of tokens","items":{"$ref":"#/components/schemas/TokenInfo"}},"total":{"type":"integer","description":"Total number of tokens matching the search criteria","example":10}},"required":["tokens","total"]}}}} \ No newline at end of file diff --git a/scripts/verify-aiproxy-provisioning.ts b/scripts/verify-aiproxy-provisioning.ts new file mode 100644 index 0000000..c2c5c86 --- /dev/null +++ b/scripts/verify-aiproxy-provisioning.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict' +import { AIPROXY_AUTO_TOKEN_NAME, AIPROXY_MODEL_BASE_URL } from '@/lib/aiproxy/constants' +import { getAiProxyProvisioningTask, resetAiProxyProvisioningTaskForTests } from '@/lib/aiproxy/client-provisioning' +import { diagnoseAiProxyTokenInfo, isUsableAiProxyTokenInfo } from '@/lib/aiproxy/token-validation' + +async function testGatewayBaseUrlUsesOpenAiPath() { + assert.equal(AIPROXY_MODEL_BASE_URL, 'https://aiproxy.usw-1.sealos.io/v1') +} + +async function testTokenValidationRejectsWrongName() { + assert.equal( + diagnoseAiProxyTokenInfo({ + expired_at: -1, + id: 1, + key: 'sk-1234567890', + name: 'other', + status: 1, + }), + 'wrong_name', + ) + + assert.equal( + isUsableAiProxyTokenInfo({ + id: 1, + key: 'sk-1234567890', + name: 'other', + status: 1, + }), + false, + ) +} + +async function testTokenValidationIgnoresExpirationField() { + assert.equal( + diagnoseAiProxyTokenInfo({ + expired_at: 10, + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + null, + ) + + assert.equal( + isUsableAiProxyTokenInfo({ + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + true, + ) +} + +async function testTokenValidationAcceptsUsableToken() { + assert.equal( + diagnoseAiProxyTokenInfo({ + expired_at: -1, + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + null, + ) + + assert.equal( + isUsableAiProxyTokenInfo({ + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + true, + ) +} + +async function testTokenValidationReportsMissingExpiration() { + assert.equal( + diagnoseAiProxyTokenInfo({ + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + null, + ) + + assert.equal( + isUsableAiProxyTokenInfo({ + id: 1, + key: 'sk-1234567890', + name: AIPROXY_AUTO_TOKEN_NAME, + status: 1, + }), + true, + ) +} + +async function testClientProvisioningReusesInflightRequest() { + resetAiProxyProvisioningTaskForTests() + + let callCount = 0 + const runProvisioning = async () => { + callCount += 1 + await Promise.resolve() + return true + } + + const first = getAiProxyProvisioningTask(runProvisioning) + const second = getAiProxyProvisioningTask(runProvisioning) + + assert.equal(first, second) + assert.equal(await first, true) + assert.equal(callCount, 1) +} + +async function testClientProvisioningRetriesAfterFailure() { + resetAiProxyProvisioningTaskForTests() + + let callCount = 0 + const failing = async () => { + callCount += 1 + return false + } + + assert.equal(await getAiProxyProvisioningTask(failing), false) + assert.equal(await getAiProxyProvisioningTask(failing), false) + assert.equal(callCount, 2) +} + +async function main() { + await testGatewayBaseUrlUsesOpenAiPath() + await testTokenValidationRejectsWrongName() + await testTokenValidationIgnoresExpirationField() + await testTokenValidationAcceptsUsableToken() + await testTokenValidationReportsMissingExpiration() + await testClientProvisioningReusesInflightRequest() + await testClientProvisioningRetriesAfterFailure() + console.info('AIProxy provisioning verification passed') +} + +main().catch(() => { + console.error('AIProxy provisioning verification failed') + process.exitCode = 1 +})