From 9fb947aa7db758ba41c5e1b60332823d63343715 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:33:17 +0800 Subject: [PATCH] Add runtime provider controls and searchable AI routing Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants. --- README.md | 6 +- admin-web/src/api/client.ts | 20 + admin-web/src/api/types.ts | 22 +- admin-web/src/app.tsx | 14 + .../src/features/providers/providers-page.tsx | 229 +++++++++++ admin-web/src/test/client.test.ts | 26 ++ admin-web/src/test/providers.test.tsx | 84 +++++ deploy/smoke-local.sh | 4 +- deploy/smoke/runtime-grants.sql | 2 + docs/mysql-minimum-privileges.sql | 3 + docs/openapi.yaml | 90 ++++- .../kotlin/com/osglab/account/Application.kt | 45 ++- .../features/admin/models/AdminModels.kt | 1 + .../features/admin/routes/AdminRoutes.kt | 48 +++ .../credentials/GatewayCredentialModels.kt | 44 +++ .../GatewayCredentialRepository.kt | 90 +++++ .../credentials/GatewayCredentialService.kt | 60 +++ .../credentials/ProviderApiKeyResolver.kt | 76 ++++ .../features/gateway/models/GatewayModels.kt | 9 + .../providers/deepseek/DeepSeekProvider.kt | 117 ++++-- .../deepseek/DeepSeekResponsesClient.kt | 356 ++++++++++++++++++ .../volcengine/VolcengineAsrProvider.kt | 9 +- .../features/gateway/routes/GatewayRoutes.kt | 2 +- .../services/GatewayTaskPolicyResolver.kt | 13 +- .../gateway/services/GatewayUsageEstimator.kt | 12 +- .../features/oobe/ExposedOobeRepository.kt | 18 +- .../account/features/oobe/OobeModels.kt | 1 + .../account/features/oobe/OobeRepository.kt | 2 +- .../V27__scope_oobe_claims_to_grant.sql | 23 ++ .../V28__runtime_provider_api_keys.sql | 13 + .../config/DeploymentConsistencyTest.kt | 16 + .../account/config/SmokeDeploymentTest.kt | 2 +- .../features/admin/routes/AdminRoutesTest.kt | 87 +++++ ...ewayCredentialRepositoryIntegrationTest.kt | 121 ++++++ .../GatewayCredentialServiceTest.kt | 136 +++++++ .../gateway/models/TextRequestPolicyTest.kt | 1 + .../providers/deepseek/DeepSeekClientTest.kt | 190 +++++++++- .../gateway/routes/GatewayRequestIdTest.kt | 21 ++ .../services/GatewayServiceBillingTest.kt | 2 +- .../services/GatewayTaskPolicyResolverTest.kt | 55 ++- .../services/GatewayUsageEstimatorTest.kt | 40 ++ .../features/oobe/OobeGatewayServiceTest.kt | 26 +- .../oobe/OobeRepositoryIntegrationTest.kt | 23 +- 43 files changed, 2079 insertions(+), 80 deletions(-) create mode 100644 admin-web/src/features/providers/providers-page.tsx create mode 100644 admin-web/src/test/providers.test.tsx create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialModels.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/credentials/ProviderApiKeyResolver.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt create mode 100644 src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql create mode 100644 src/main/resources/db/migration/V28__runtime_provider_api_keys.sql create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimatorTest.kt diff --git a/README.md b/README.md index c821147..a5a80d0 100644 --- a/README.md +++ b/README.md @@ -170,8 +170,10 @@ Gateway text requests may include the optional stable `taskKind` values document `docs/openapi.yaml`. The server maps `capability + taskKind` to a deterministic execution policy; it never infers task type from user content. Polish and transform tasks explicitly disable DeepSeek thinking and do not retry an empty buffered result. AI questions and agent planning explicitly use -high-effort thinking. Search and tools remain disabled for every task because no safe, billable -implementation is configured. +high-effort thinking. Ordinary AI questions allow model-selected DeepSeek Responses web search; +`current_information_question` requires web search. Search failures fall back to Chat Completions +before any result is emitted. Other tools remain disabled, and search usage is billed only through +the provider-reported LLM input and output Token counts. Store production values in 1Panel's secret/environment facility. The Compose environment receives them at runtime because this application does not read Docker `/run/secrets/*` files directly. diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index f2b5787..7be0deb 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -14,6 +14,9 @@ import type { CreditGrantResponse, LedgerQuery, LedgerEntry, + ManagedProviderId, + ManagedProviderOverview, + ManagedProviderStatus, OperatorsQuery, Overview, PageResult, @@ -30,6 +33,7 @@ import type { UpdateHintPackRequest, UpdateHintFeedSettingsRequest, UpdateOfficialSkillRequest, + UpdateProviderApiKeyRequest, } from "./types"; const API_BASE = "/v1/admin"; @@ -89,6 +93,8 @@ function safeMessage(status: number, code?: string): string { HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新", HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求", HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用", + PROVIDER_API_KEY_INVALID: "API Key 不符合要求", + PROVIDER_NOT_FOUND: "不支持该 Provider", RATE_LIMITED: "操作过于频繁,请稍后再试", }; if (code && messages[code]) return messages[code]; @@ -285,6 +291,20 @@ export const adminApi = { headers: { "Idempotency-Key": payload.idempotencyKey }, }), + providers: () => request("/providers"), + + updateProviderApiKey: ( + providerId: ManagedProviderId, + payload: UpdateProviderApiKeyRequest, + ) => + request( + `/providers/${encodeURIComponent(providerId)}/api-key`, + { + method: "PUT", + body: JSON.stringify(payload), + }, + ), + auditLogs: (value?: string | AuditQuery) => request>( `/audit${encodeQuery(cursorQuery(value))}`, diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index 2cd4c2d..3f71d16 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -19,7 +19,8 @@ export type AdminAuditAction = | "CONTENT_HINT_PACK_PUBLISHED" | "CONTENT_HINT_PACK_SAVED" | "CONTENT_HINT_FEED_SETTINGS_UPDATED" - | "CONTENT_HINT_FEED_GENERATED"; + | "CONTENT_HINT_FEED_GENERATED" + | "PROVIDER_API_KEY_UPDATED"; export interface SkillLocalization { name: string; @@ -123,6 +124,25 @@ export interface HintFeedGenerationResponse { en: { version: number; cardCount: number }; } +export type ManagedProviderId = "deepseek" | "volcengine"; + +export type ProviderCredentialSource = "ENVIRONMENT" | "RUNTIME_OVERRIDE"; + +export interface ManagedProviderStatus { + providerId: ManagedProviderId; + configured: boolean; + source: ProviderCredentialSource; + updatedAt?: string; +} + +export interface ManagedProviderOverview { + providers: ManagedProviderStatus[]; +} + +export interface UpdateProviderApiKeyRequest { + apiKey: string; +} + export interface CursorPageQuery { cursor?: string; limit?: number; diff --git a/admin-web/src/app.tsx b/admin-web/src/app.tsx index a38bf8e..2fdd8c5 100644 --- a/admin-web/src/app.tsx +++ b/admin-web/src/app.tsx @@ -5,6 +5,7 @@ import { ChevronRight, Coins, GitBranch, + KeyRound, LibraryBig, LogOut, Menu, @@ -81,6 +82,11 @@ const SecurityPage = lazy(() => default: module.SecurityPage, })), ); +const ProvidersPage = lazy(() => + import("./features/providers/providers-page").then((module) => ({ + default: module.ProvidersPage, + })), +); interface NavItem { path: string; @@ -150,6 +156,13 @@ const navigation: NavItem[] = [ icon: ShieldCheck, roles: ["SUPER_ADMIN"], }, + { + path: "/providers", + label: "Provider 配置", + description: "上游密钥管理", + icon: KeyRound, + roles: ["SUPER_ADMIN"], + }, ]; export function App() { @@ -224,6 +237,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) { <> } /> } /> + } /> ) : null} } /> diff --git a/admin-web/src/features/providers/providers-page.tsx b/admin-web/src/features/providers/providers-page.tsx new file mode 100644 index 0000000..d60d3be --- /dev/null +++ b/admin-web/src/features/providers/providers-page.tsx @@ -0,0 +1,229 @@ +import { KeyRound, RefreshCw, ServerCog } from "lucide-react"; +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { adminApi, ApiError } from "../../api/client"; +import type { + ManagedProviderId, + ManagedProviderOverview, + ManagedProviderStatus, +} from "../../api/types"; +import { + Badge, + Button, + Card, + ErrorState, + Input, + LoadingState, + PageHeader, +} from "../../components/primitives"; +import { formatDateTime } from "../../lib/format"; + +const PROVIDERS: Array<{ + id: ManagedProviderId; + name: string; + description: string; +}> = [ + { + id: "deepseek", + name: "DeepSeek", + description: "用于润色、AI 和 Agent 等托管文本能力。", + }, + { + id: "volcengine", + name: "火山引擎", + description: "用于托管语音识别能力,仅替换 API Key。", + }, +]; + +export function ProvidersPage() { + const [overview, setOverview] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + const load = useCallback(async () => { + setLoading(true); + setError(undefined); + try { + setOverview(await adminApi.providers()); + } catch (cause) { + setError(cause); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const updateStatus = useCallback((updated: ManagedProviderStatus) => { + setOverview((current) => { + if (!current) return current; + const providers = current.providers.filter( + (item) => item.providerId !== updated.providerId, + ); + return { providers: [...providers, updated] }; + }); + }, []); + + if (loading) return ; + if (error || !overview) return void load()} />; + + return ( +
+ void load()}> + + 刷新状态 + + } + /> + + +
+ + + +

+ 保存后无法从页面读取原 Key。请先确认新 Key 已启用且权限正确;操作会写入审计日志,但不会记录密钥内容。 +

+
+
+ +
+ {PROVIDERS.map((provider) => ( + item.providerId === provider.id)} + onUpdated={updateStatus} + /> + ))} +
+
+ ); +} + +function ProviderCard({ + providerId, + name, + description, + status, + onUpdated, +}: { + providerId: ManagedProviderId; + name: string; + description: string; + status?: ManagedProviderStatus; + onUpdated: (status: ManagedProviderStatus) => void; +}) { + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const normalized = apiKey.trim(); + if (!validApiKey(normalized)) { + toast.error("API Key 必须为 1–512 个字符,且不能包含换行"); + return; + } + if (!window.confirm(`确定替换 ${name} API Key?保存后,新请求将立即使用该 Key。`)) { + return; + } + + setSaving(true); + try { + const updated = await adminApi.updateProviderApiKey(providerId, { + apiKey: normalized, + }); + setApiKey(""); + onUpdated(updated); + toast.success(`${name} API Key 已替换`); + } catch (error) { + toast.error(error instanceof ApiError ? error.message : `${name} API Key 替换失败`); + } finally { + setSaving(false); + } + } + + return ( + +
+
+ + + +
+
+

{name}

+ + {status?.configured ? "已配置" : "未配置"} + +
+

{description}

+
+
+ +
+ + +
+
+ +
void submit(event)}> + +

+ 留空不会修改配置。出于安全考虑,当前 Key 不会显示。 +

+ +
+
+ ); +} + +function StatusItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function sourceLabel(status?: ManagedProviderStatus): string { + if (!status?.configured) return "尚未配置"; + return status.source === "RUNTIME_OVERRIDE" ? "管理后台" : "环境变量"; +} + +function validApiKey(value: string): boolean { + return ( + value.length >= 1 && + value.length <= 512 && + !value.includes("\n") && + !value.includes("\r") + ); +} diff --git a/admin-web/src/test/client.test.ts b/admin-web/src/test/client.test.ts index 989473e..3398192 100644 --- a/admin-web/src/test/client.test.ts +++ b/admin-web/src/test/client.test.ts @@ -276,4 +276,30 @@ describe("adminApi", () => { ); expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP"); }); + + it("Provider API Key 替换请求携带 CSRF 且只提交新 Key", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + providerId: "deepseek", + configured: true, + source: "RUNTIME_OVERRIDE", + updatedAt: "2026-08-22T08:00:00Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + setCsrfToken("csrf-provider"); + + await adminApi.updateProviderApiKey("deepseek", { apiKey: "new-provider-key" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/v1/admin/providers/deepseek/api-key", + ); + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(request.method).toBe("PUT"); + expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider"); + expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" })); + }); }); diff --git a/admin-web/src/test/providers.test.tsx b/admin-web/src/test/providers.test.tsx new file mode 100644 index 0000000..a33108e --- /dev/null +++ b/admin-web/src/test/providers.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { adminApi } from "../api/client"; +import type { ManagedProviderOverview } from "../api/types"; +import { App } from "../app"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.location.hash = ""; +}); + +describe("Provider 配置", () => { + it("仅展示配置状态,不会回显现有 API Key", async () => { + mockSession(); + mockProviders(); + window.location.hash = "#/providers"; + + render(); + + expect(await screen.findByRole("heading", { name: "Provider 配置" })).toBeTruthy(); + expect(screen.getByText("DeepSeek")).toBeTruthy(); + expect(screen.getByText("火山引擎")).toBeTruthy(); + expect(screen.getByText("管理后台")).toBeTruthy(); + expect(screen.getByText("环境变量")).toBeTruthy(); + expect(document.body.textContent).not.toContain("existing-secret"); + }); + + it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => { + mockSession(); + mockProviders(); + vi.spyOn(window, "confirm").mockReturnValue(true); + const update = vi.spyOn(adminApi, "updateProviderApiKey").mockResolvedValue({ + providerId: "deepseek", + configured: true, + source: "RUNTIME_OVERRIDE", + updatedAt: "2026-08-22T08:00:00Z", + }); + window.location.hash = "#/providers"; + render(); + + const input = await screen.findByLabelText("DeepSeek 新 API Key"); + await userEvent.type(input, "new-deepseek-key"); + await userEvent.click( + screen.getByRole("button", { name: "替换 DeepSeek API Key" }), + ); + + await waitFor(() => + expect(update).toHaveBeenCalledWith("deepseek", { apiKey: "new-deepseek-key" }), + ); + await waitFor(() => expect(input).toHaveProperty("value", "")); + }); +}); + +function mockSession() { + vi.spyOn(adminApi, "session").mockResolvedValue({ + authenticated: true, + operatorName: "owner", + role: "SUPER_ADMIN", + }); +} + +function mockProviders() { + vi.spyOn(adminApi, "providers").mockResolvedValue(providerOverview()); +} + +function providerOverview(): ManagedProviderOverview { + return { + providers: [ + { + providerId: "deepseek", + configured: true, + source: "RUNTIME_OVERRIDE", + updatedAt: "2026-08-22T07:30:00Z", + }, + { + providerId: "volcengine", + configured: true, + source: "ENVIRONMENT", + }, + ], + }; +} diff --git a/deploy/smoke-local.sh b/deploy/smoke-local.sh index 4eee5bc..745b0d9 100755 --- a/deploy/smoke-local.sh +++ b/deploy/smoke-local.sh @@ -477,9 +477,9 @@ WHERE version IS NOT NULL ORDER BY installed_rank; SQL )" -EXPECTED_MIGRATIONS="$(seq 1 26 | awk '{ print $1 ":1" }')" +EXPECTED_MIGRATIONS="$(seq 1 27 | awk '{ print $1 ":1" }')" [[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] || - fail "Flyway history was not exactly successful V1-V26" + fail "Flyway history was not exactly successful V1-V27" REFERRAL_REWARDS="$( mysql_root --batch --skip-column-names osg_account_smoke <<'SQL' SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits) diff --git a/deploy/smoke/runtime-grants.sql b/deploy/smoke/runtime-grants.sql index 232a45f..2bfae15 100644 --- a/deploy/smoke/runtime-grants.sql +++ b/deploy/smoke/runtime-grants.sql @@ -34,6 +34,7 @@ GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.gateway_provider_credentials TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.product_analytics_installations TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%'; @@ -80,6 +81,7 @@ GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime' GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%'; GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%'; GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.gateway_provider_credentials TO 'osg_smoke_runtime'@'%'; GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%'; GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations TO 'osg_smoke_runtime'@'%'; diff --git a/docs/mysql-minimum-privileges.sql b/docs/mysql-minimum-privileges.sql index 307310e..0bc024f 100644 --- a/docs/mysql-minimum-privileges.sql +++ b/docs/mysql-minimum-privileges.sql @@ -46,6 +46,7 @@ GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.gateway_provider_credentials TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.product_analytics_installations TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%'; @@ -94,6 +95,8 @@ GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10 GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.gateway_provider_credentials + TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations TO 'osg_account_runtime'@'10.20.%'; diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8fc46b3..ac5b702 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -387,7 +387,9 @@ paths: summary: Create a short-lived anonymous OOBE gateway grant description: | Verifies an App Attest assertion bound to the installation and returns - credentials limited to the four one-time onboarding AI features. + credentials limited to the four onboarding AI pages. Each feature can + succeed once within this short-lived grant; a later OOBE run receives + a new grant so the guided experience remains repeatable. requestBody: required: true content: @@ -477,11 +479,16 @@ paths: The server deterministically selects model, thinking, search, tools, retry, and output-budget policy from `capability` plus optional `taskKind`. It never infers task type from `input` or `context`, and clients cannot - supply provider parameters. Search and tools are currently disabled. + supply provider parameters. Ordinary AI questions allow model-selected + server-side web search; current-information questions require it. Other + tools remain disabled. Search has no separate credit fee; settlement + uses the provider-reported LLM input and output Token counts, including + any search context charged by the provider. For account grants, `oobe` is accepted only for dictation polish and the first successful request per account is complimentary. Anonymous OOBE grants require a matching `oobeFeature` and allow one successful request - per feature. Later attempts fail without falling through to paid billing. + per feature within that grant. A new OOBE grant starts a fresh guided + session; repeated calls within one page still fail without paid fallback. parameters: - $ref: "#/components/parameters/RequestId" - name: capability @@ -914,6 +921,63 @@ paths: responses: "204": { description: Session revoked } "401": { description: Session is invalid } + /v1/admin/providers: + get: + security: + - adminMtls: [] + adminSession: [] + summary: List effective provider credential status without returning secrets + responses: + "200": + description: Provider credential status + content: + application/json: + schema: + type: array + minItems: 2 + maxItems: 2 + items: { $ref: "#/components/schemas/ProviderCredentialStatus" } + "403": { description: SUPER_ADMIN role is required } + /v1/admin/providers/{providerId}/api-key: + put: + security: + - adminMtls: [] + adminSession: [] + summary: Replace the runtime provider API key for new upstream requests + parameters: + - name: providerId + in: path + required: true + schema: { type: string, enum: [deepseek, volcengine] } + - $ref: "#/components/parameters/AdminCsrf" + - name: X-Request-ID + in: header + required: false + schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" } + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [apiKey] + properties: + apiKey: + type: string + minLength: 1 + maxLength: 4096 + pattern: "^[^\\r\\n]+$" + writeOnly: true + responses: + "200": + description: Runtime override saved, encrypted, and audited + content: + application/json: + schema: { $ref: "#/components/schemas/ProviderCredentialStatus" } + "400": { description: API key is blank, multiline, oversized, or malformed } + "403": { description: SUPER_ADMIN role and valid CSRF are required } + "404": { description: Provider is not supported } /v1/admin/overview: get: security: @@ -1293,6 +1357,7 @@ paths: - CONTENT_HINT_PACK_SAVED - CONTENT_HINT_FEED_SETTINGS_UPDATED - CONTENT_HINT_FEED_GENERATED + - PROVIDER_API_KEY_UPDATED - name: result in: query schema: { type: string, enum: [success, rejected] } @@ -1812,6 +1877,19 @@ components: type: ["string", "null"] enum: [SUPER_ADMIN, SUPPORT, ANALYST, null] description: CSRF material is intentionally not reconstructed or returned by session checks. + ProviderCredentialStatus: + type: object + additionalProperties: false + required: [providerId, configured, source] + properties: + providerId: { type: string, enum: [deepseek, volcengine] } + configured: { type: boolean } + source: { type: string, enum: [ENVIRONMENT, RUNTIME_OVERRIDE] } + updatedAt: + type: ["string", "null"] + format: date-time + description: Present only for a runtime override. + description: API key material is never returned. AdminLoginResponse: type: object additionalProperties: false @@ -2648,6 +2726,7 @@ components: - translation - edit_last_input - ai_question + - current_information_question - clipboard_transform - custom_skill - agent_planning @@ -2655,7 +2734,8 @@ components: description: | Optional deterministic task selector. Allowed combinations are: `polish` with `dictation_polish`, `translation`, or `edit_last_input`; - `ai` with `ai_question`, `clipboard_transform`, or `custom_skill`; + `ai` with `ai_question`, `current_information_question`, + `clipboard_transform`, or `custom_skill`; and `agent` with `agent_planning`. Omission defaults respectively to `dictation_polish`, `ai_question`, and `agent_planning`. A mismatch returns `400 invalid_request`. @@ -2676,7 +2756,7 @@ components: description: | Required for anonymous OOBE grants. The server validates that the feature matches the requested capability and task kind, and allows - each feature to succeed only once per installation-bound subject. + each feature to succeed only once per short-lived OOBE grant. CreateOobeGrantRequest: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index b2c74c4..73d6af5 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -88,6 +88,12 @@ import com.osglab.account.features.gateway.adapters.CreditReservationAdapter import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter import com.osglab.account.features.gateway.GatewaySettings import com.osglab.account.features.gateway.asr.AsrStreamingService +import com.osglab.account.features.gateway.credentials.DatabaseProviderApiKeyResolver +import com.osglab.account.features.gateway.credentials.EnvironmentProviderCredentials +import com.osglab.account.features.gateway.credentials.ExposedGatewayCredentialRepository +import com.osglab.account.features.gateway.credentials.GatewayCredentialRepository +import com.osglab.account.features.gateway.credentials.GatewayCredentialService +import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver import com.osglab.account.features.gateway.ports.CreditReservationPort import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort @@ -272,7 +278,11 @@ fun Application.module() { val providerConfig = appConfig.providers.volcengine.toProviderConfig() AsrStreamingService( gateway = koin.get(), - upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig), + upstream = KtorVolcengineAsrTransport( + client = koin.get(), + config = providerConfig, + credentialResolver = koin.get(), + ), scope = this, ) } else { @@ -367,6 +377,7 @@ fun Application.module() { grantService = koin.get(), operatorService = koin.get(), auditService = koin.get(), + credentialService = koin.get(), contentService = koin.get(), hintFeedService = koin.get(), ) @@ -399,6 +410,19 @@ fun accountServerModule(config: AppConfig): Module = module { single { SessionJwt(config.session) } single { FieldEncryptor(config.encryption.key) } single { IdentityFingerprint(config.antiAbuse.identityHmacKey) } + single { ExposedGatewayCredentialRepository(get()) } + single { + EnvironmentProviderCredentials( + deepSeekApiKey = config.providers.deepSeek.apiKey, + volcengineApiKey = config.providers.volcengine.apiKey, + volcengineLegacyConfigured = + !config.providers.volcengine.appId.isNullOrBlank() && + !config.providers.volcengine.accessToken.isNullOrBlank(), + ) + } + single { DatabaseProviderApiKeyResolver(get(), get(), get()) } + single { get() } + single { GatewayCredentialService(get(), get(), get()) } single { ExposedAdminRepository(get()) } single { BouncyCastleArgon2idPasswordHasher() } single { HmacTotpVerifier() } @@ -667,7 +691,7 @@ fun accountServerModule(config: AppConfig): Module = module { ) } single { - ProviderCatalog(configuredProviders(config, get())) + ProviderCatalog(configuredProviders(config, get(), get())) } single { GatewayService(get(), get(), get(), get(), get(), get()) } single { GatewayReconciliationService(get(), get()) } @@ -680,7 +704,11 @@ fun accountServerModule(config: AppConfig): Module = module { } } -private fun configuredProviders(config: AppConfig, client: HttpClient): List = +private fun configuredProviders( + config: AppConfig, + client: HttpClient, + credentialResolver: ProviderApiKeyResolver, +): List = buildList { config.providers.deepSeek.apiKey?.let { apiKey -> add( @@ -692,12 +720,21 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List + get("/providers") { + if ( + call.requireRole( + config, + sessionService, + setOf(AdminRole.SUPER_ADMIN), + ) == null + ) return@get + call.respond(service.listStatuses()) + } + + put("/providers/{providerId}/api-key") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put + if (principal.role != AdminRole.SUPER_ADMIN) { + call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION")) + return@put + } + val provider = GatewayCredentialProvider.fromProviderId( + call.parameters["providerId"], + ) ?: run { + call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND")) + return@put + } + val request = call.receiveAdminRequest() ?: return@put + val status = try { + service.updateApiKey( + provider = provider, + apiKey = request.apiKey, + operatorId = principal.operatorId, + requestId = call.request.header("X-Request-ID"), + ) + } catch (_: InvalidProviderApiKeyException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@put + } + call.respond(status) + } + } + get("/overview") { if (call.requirePrincipal(config, sessionService) == null) return@get val stats = statsService.getRange(call.request.queryParameters["range"], clock) @@ -1098,6 +1143,9 @@ private data class AdminGrantRequest(val userId: String, val amount: Long, val r @Serializable private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long) +@Serializable +private data class ProviderApiKeyUpdateRequest(val apiKey: String) + @Serializable private data class AdminOperatorCreateRequest( val username: String, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialModels.kt new file mode 100644 index 0000000..453374e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialModels.kt @@ -0,0 +1,44 @@ +package com.osglab.account.features.gateway.credentials + +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +enum class GatewayCredentialProvider(val providerId: String) { + DEEPSEEK("deepseek"), + VOLCENGINE("volcengine"); + + companion object { + fun fromProviderId(value: String?): GatewayCredentialProvider? = + entries.firstOrNull { it.providerId == value } + } +} + +@Serializable +enum class GatewayCredentialSource { + ENVIRONMENT, + RUNTIME_OVERRIDE, +} + +@Serializable +data class GatewayCredentialStatus( + val providerId: String, + val configured: Boolean, + val source: GatewayCredentialSource, + val updatedAt: String? = null, +) + +/** + * Persistence-only encrypted value. Its string representation deliberately + * excludes ciphertext so authenticated encryption material cannot reach logs. + */ +class ProviderApiKeyOverride( + val provider: GatewayCredentialProvider, + val encryptedApiKey: String, + val updatedAt: Instant, + val updatedByOperatorId: UUID, +) { + override fun toString(): String = + "ProviderApiKeyOverride(provider=${provider.providerId}, updatedAt=$updatedAt, " + + "updatedByOperatorId=$updatedByOperatorId)" +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepository.kt b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepository.kt new file mode 100644 index 0000000..d2d670a --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepository.kt @@ -0,0 +1,90 @@ +package com.osglab.account.features.gateway.credentials + +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import com.osglab.account.features.admin.repositories.AdminAuditLogTable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertIgnore +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.util.UUID + +internal object GatewayProviderCredentialsTable : Table("gateway_provider_credentials") { + val providerId = varchar("provider_id", 32) + val encryptedApiKey = text("encrypted_api_key") + val updatedAt = timestamp("updated_at") + val updatedByOperatorId = varchar("updated_by_operator_id", 36) + override val primaryKey = PrimaryKey(providerId) +} + +interface GatewayCredentialRepository { + suspend fun findOverride(provider: GatewayCredentialProvider): ProviderApiKeyOverride? + + /** + * Stores the current override and its successful audit event atomically. + */ + suspend fun upsertOverride( + credentialOverride: ProviderApiKeyOverride, + auditEvent: NewAdminAuditEvent, + ) +} + +class ExposedGatewayCredentialRepository( + private val databaseFactory: DatabaseFactory, +) : GatewayCredentialRepository { + override suspend fun findOverride( + provider: GatewayCredentialProvider, + ): ProviderApiKeyOverride? = databaseFactory.query { + GatewayProviderCredentialsTable.selectAll() + .where { GatewayProviderCredentialsTable.providerId eq provider.providerId } + .limit(1) + .singleOrNull() + ?.toOverride() + } + + override suspend fun upsertOverride( + credentialOverride: ProviderApiKeyOverride, + auditEvent: NewAdminAuditEvent, + ) { + databaseFactory.query { + val inserted = GatewayProviderCredentialsTable.insertIgnore { + it[providerId] = credentialOverride.provider.providerId + it[encryptedApiKey] = credentialOverride.encryptedApiKey + it[updatedAt] = credentialOverride.updatedAt + it[updatedByOperatorId] = credentialOverride.updatedByOperatorId.toString() + }.insertedCount > 0 + if (!inserted) { + GatewayProviderCredentialsTable.update({ + GatewayProviderCredentialsTable.providerId eq credentialOverride.provider.providerId + }) { + it[encryptedApiKey] = credentialOverride.encryptedApiKey + it[updatedAt] = credentialOverride.updatedAt + it[updatedByOperatorId] = credentialOverride.updatedByOperatorId.toString() + } + } + AdminAuditLogTable.insert { + it[id] = auditEvent.id.toString() + it[actorOperatorId] = auditEvent.actorOperatorId?.toString() + it[action] = auditEvent.action.name + it[outcome] = auditEvent.outcome.name + it[targetType] = auditEvent.targetType + it[targetId] = auditEvent.targetId + it[requestId] = auditEvent.requestId + it[occurredAt] = auditEvent.occurredAt + } + } + } +} + +private fun ResultRow.toOverride(): ProviderApiKeyOverride = ProviderApiKeyOverride( + provider = requireNotNull( + GatewayCredentialProvider.fromProviderId(this[GatewayProviderCredentialsTable.providerId]), + ), + encryptedApiKey = this[GatewayProviderCredentialsTable.encryptedApiKey], + updatedAt = this[GatewayProviderCredentialsTable.updatedAt], + updatedByOperatorId = UUID.fromString(this[GatewayProviderCredentialsTable.updatedByOperatorId]), +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt new file mode 100644 index 0000000..504e461 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt @@ -0,0 +1,60 @@ +package com.osglab.account.features.gateway.credentials + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import java.time.Clock +import java.util.UUID + +class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key") + +class GatewayCredentialService( + private val repository: GatewayCredentialRepository, + private val resolver: DatabaseProviderApiKeyResolver, + private val fieldEncryptor: FieldEncryptor, + private val clock: Clock = Clock.systemUTC(), +) { + suspend fun listStatuses(): List = + GatewayCredentialProvider.entries.map { resolver.status(it) } + + suspend fun updateApiKey( + provider: GatewayCredentialProvider, + apiKey: String, + operatorId: UUID, + requestId: String?, + ): GatewayCredentialStatus { + val normalized = apiKey.trim() + if ( + normalized.isEmpty() || + normalized.length > MAX_API_KEY_LENGTH || + apiKey.contains('\r') || + apiKey.contains('\n') + ) { + throw InvalidProviderApiKeyException() + } + val now = clock.instant() + repository.upsertOverride( + credentialOverride = ProviderApiKeyOverride( + provider = provider, + encryptedApiKey = fieldEncryptor.encrypt(normalized, encryptionContext(provider)), + updatedAt = now, + updatedByOperatorId = operatorId, + ), + auditEvent = NewAdminAuditEvent( + actorOperatorId = operatorId, + action = AdminAuditAction.PROVIDER_API_KEY_UPDATED, + outcome = AdminAuditOutcome.SUCCESS, + targetType = "PROVIDER", + targetId = provider.providerId, + requestId = requestId, + occurredAt = now, + ), + ) + return resolver.status(provider) + } + + private companion object { + const val MAX_API_KEY_LENGTH = 4_096 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/credentials/ProviderApiKeyResolver.kt b/src/main/kotlin/com/osglab/account/features/gateway/credentials/ProviderApiKeyResolver.kt new file mode 100644 index 0000000..2c3e472 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/credentials/ProviderApiKeyResolver.kt @@ -0,0 +1,76 @@ +package com.osglab.account.features.gateway.credentials + +import com.osglab.account.common.security.FieldEncryptor + +fun interface ProviderApiKeyResolver { + /** + * Resolves the effective API key at the start of a new upstream request. + */ + suspend fun resolve(provider: GatewayCredentialProvider): String? +} + +class EnvironmentProviderCredentials( + val deepSeekApiKey: String?, + val volcengineApiKey: String?, + val volcengineLegacyConfigured: Boolean, +) { + fun apiKey(provider: GatewayCredentialProvider): String? = + when (provider) { + GatewayCredentialProvider.DEEPSEEK -> deepSeekApiKey + GatewayCredentialProvider.VOLCENGINE -> volcengineApiKey + }?.trim()?.takeIf(String::isNotEmpty) + + fun configured(provider: GatewayCredentialProvider): Boolean = + apiKey(provider) != null || + (provider == GatewayCredentialProvider.VOLCENGINE && volcengineLegacyConfigured) + + override fun toString(): String = + "EnvironmentProviderCredentials(deepSeekConfigured=${!deepSeekApiKey.isNullOrBlank()}, " + + "volcengineApiKeyConfigured=${!volcengineApiKey.isNullOrBlank()}, " + + "volcengineLegacyConfigured=$volcengineLegacyConfigured)" +} + +class DatabaseProviderApiKeyResolver( + private val repository: GatewayCredentialRepository, + private val fieldEncryptor: FieldEncryptor, + private val environment: EnvironmentProviderCredentials, +) : ProviderApiKeyResolver { + override suspend fun resolve(provider: GatewayCredentialProvider): String? = + repository.findOverride(provider)?.let { + fieldEncryptor.decrypt(it.encryptedApiKey, encryptionContext(provider)) + } ?: environment.apiKey(provider) + + suspend fun status(provider: GatewayCredentialProvider): GatewayCredentialStatus { + val runtimeOverride = repository.findOverride(provider) + return if (runtimeOverride != null) { + GatewayCredentialStatus( + providerId = provider.providerId, + configured = true, + source = GatewayCredentialSource.RUNTIME_OVERRIDE, + updatedAt = runtimeOverride.updatedAt.toString(), + ) + } else { + GatewayCredentialStatus( + providerId = provider.providerId, + configured = environment.configured(provider), + source = GatewayCredentialSource.ENVIRONMENT, + ) + } + } +} + +class StaticProviderApiKeyResolver( + deepSeekApiKey: String? = null, + volcengineApiKey: String? = null, +) : ProviderApiKeyResolver { + private val apiKeys = mapOf( + GatewayCredentialProvider.DEEPSEEK to deepSeekApiKey, + GatewayCredentialProvider.VOLCENGINE to volcengineApiKey, + ) + + override suspend fun resolve(provider: GatewayCredentialProvider): String? = + apiKeys[provider]?.trim()?.takeIf(String::isNotEmpty) +} + +internal fun encryptionContext(provider: GatewayCredentialProvider): String = + "gateway-provider-api-key:${provider.providerId}" diff --git a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt index ea3b1fd..b300e51 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt @@ -109,6 +109,9 @@ enum class GatewayTaskKind { @SerialName("ai_question") AI_QUESTION, + @SerialName("current_information_question") + CURRENT_INFORMATION_QUESTION, + @SerialName("clipboard_transform") CLIPBOARD_TRANSFORM, @@ -167,6 +170,12 @@ data class GatewayTaskExecutionPolicy( ) { "reasoning effort must be explicit exactly when thinking is enabled" } + require( + webSearch == GatewayWebSearchMode.DISABLED || + thinking == GatewayThinkingMode.ENABLED, + ) { + "web search requires an explicit thinking policy" + } } } diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt index 35874b8..45de7de 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt @@ -1,10 +1,14 @@ package com.osglab.account.features.gateway.providers.deepseek import com.osglab.account.features.gateway.agent.AgentPlan +import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider +import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver +import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver import com.osglab.account.features.gateway.models.GatewayCapability import com.osglab.account.features.gateway.models.GatewayLimits import com.osglab.account.features.gateway.models.GatewayModelProfile import com.osglab.account.features.gateway.models.GatewayReasoningEffort +import com.osglab.account.features.gateway.models.GatewayTaskKind import com.osglab.account.features.gateway.models.GatewayThinkingMode import com.osglab.account.features.gateway.models.GatewayToolsMode import com.osglab.account.features.gateway.models.GatewayWebSearchMode @@ -75,6 +79,19 @@ fun interface DeepSeekClient { suspend fun complete(request: TextProviderRequest, output: ProviderOutput): ProviderUsage } +private fun createDeepSeekClient( + client: HttpClient, + config: DeepSeekConfig, + json: Json, + credentialResolver: ProviderApiKeyResolver, +): DeepSeekClient { + val chat = KtorDeepSeekClient(client, config, json, credentialResolver) + return DeepSeekSearchFallbackClient( + search = KtorDeepSeekResponsesClient(client, config, json, credentialResolver), + fallback = chat, + ) +} + class DeepSeekProvider( private val upstream: DeepSeekClient, ) : GatewayProvider { @@ -85,7 +102,9 @@ class DeepSeekProvider( ignoreUnknownKeys = true explicitNulls = false }, - ) : this(KtorDeepSeekClient(client, config, json)) + credentialResolver: ProviderApiKeyResolver = + StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey), + ) : this(createDeepSeekClient(client, config, json, credentialResolver)) override val descriptor = ProviderDescriptor( id = "deepseek", @@ -145,8 +164,14 @@ class DeepSeekProvider( require(request.maxOutputTokens == request.executionPolicy.maxOutputTokens) { "maxOutputTokens must match the server execution policy" } - require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) { - "DeepSeek web search is not configured" + require( + request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED || + ( + request.capability == GatewayCapability.AI && + request.executionPolicy.thinking == GatewayThinkingMode.ENABLED + ) + ) { + "DeepSeek web search is supported only for reasoning AI requests" } require(request.executionPolicy.tools == GatewayToolsMode.DISABLED) { "DeepSeek tools are not configured" @@ -176,11 +201,18 @@ class KtorDeepSeekClient( ignoreUnknownKeys = true explicitNulls = false }, + private val credentialResolver: ProviderApiKeyResolver = + StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey), ) : DeepSeekClient { override suspend fun complete( request: TextProviderRequest, output: ProviderOutput, ): ProviderUsage { + require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) { + "Chat Completions cannot execute server-side web search" + } + val apiKey = credentialResolver.resolve(GatewayCredentialProvider.DEEPSEEK) + ?: throw DeepSeekConfigurationException("DeepSeek API key is not configured") val payload = DeepSeekChatRequest( model = config.modelFor(request.executionPolicy.modelProfile), messages = controlledMessages(request), @@ -203,7 +235,7 @@ class KtorDeepSeekClient( ) return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") { - bearerAuth(config.apiKey) + bearerAuth(apiKey) contentType(ContentType.Application.Json) header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json) header("X-Request-ID", request.requestId) @@ -491,34 +523,11 @@ class KtorDeepSeekClient( return bytes } - private fun controlledMessages(request: TextProviderRequest): List { - val system = when (request.capability) { - GatewayCapability.POLISH -> - "Polish the user's text while preserving meaning. Return only the polished text." - - GatewayCapability.AI -> - "Answer the user's question accurately and concisely. Do not claim actions you did not perform." - - GatewayCapability.AGENT -> - """ - Return only JSON with this schema: - {"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}. - Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs, - or claim that any client-side or external side effect occurred. - """.trimIndent() - - GatewayCapability.ASR -> error("ASR is not a DeepSeek capability") - } - val userText = buildString { - request.context?.takeIf(String::isNotBlank)?.let { - append("Context:\n") - append(it) - append("\n\n") - } - append(request.input) - } - return listOf(ChatMessage("system", system), ChatMessage("user", userText)) - } + private fun controlledMessages(request: TextProviderRequest): List = + listOf( + ChatMessage("system", deepSeekSystemInstruction(request)), + ChatMessage("user", deepSeekUserText(request)), + ) private fun GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort = when (this) { @@ -539,6 +548,50 @@ class KtorDeepSeekClient( } } +internal fun deepSeekSystemInstruction(request: TextProviderRequest): String = + when (request.capability) { + GatewayCapability.POLISH -> + "Polish the user's text while preserving meaning. Return only the polished text." + + GatewayCapability.AI -> buildString { + append("Answer the user's question accurately and concisely. ") + append("Do not claim actions you did not perform.") + if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED && + request.executionPolicy.taskKind in SEARCHABLE_AI_TASKS + ) { + append( + " Web search is temporarily unavailable. Do not present time-sensitive " + + "information as current; clearly state that it could not be verified.", + ) + } + } + + GatewayCapability.AGENT -> + """ + Return only JSON with this schema: + {"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}. + Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs, + or claim that any client-side or external side effect occurred. + """.trimIndent() + + GatewayCapability.ASR -> error("ASR is not a DeepSeek capability") + } + +internal fun deepSeekUserText(request: TextProviderRequest): String = + buildString { + request.context?.takeIf(String::isNotBlank)?.let { + append("Context:\n") + append(it) + append("\n\n") + } + append(request.input) + } + +private val SEARCHABLE_AI_TASKS = setOf( + GatewayTaskKind.AI_QUESTION, + GatewayTaskKind.CURRENT_INFORMATION_QUESTION, +) + @Serializable private data class DeepSeekChatRequest( val model: String, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt new file mode 100644 index 0000000..a585be9 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt @@ -0,0 +1,356 @@ +package com.osglab.account.features.gateway.providers.deepseek + +import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider +import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver +import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver +import com.osglab.account.features.gateway.models.GatewayLimits +import com.osglab.account.features.gateway.models.GatewayWebSearchMode +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.TextProviderRequest +import com.osglab.account.features.gateway.models.UsageMeter +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.header +import io.ktor.client.request.preparePost +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readRemaining +import kotlinx.coroutines.CancellationException +import kotlinx.io.readByteArray +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import org.slf4j.LoggerFactory + +/** + * Uses DeepSeek Responses only when server policy permits web search. The + * search attempt is fully buffered so a failure can safely fall back without + * mixing two answers in a downstream stream. + */ +internal class DeepSeekSearchFallbackClient( + private val search: DeepSeekClient, + private val fallback: DeepSeekClient, +) : DeepSeekClient { + override suspend fun complete( + request: TextProviderRequest, + output: ProviderOutput, + ): ProviderUsage { + if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) { + return fallback.complete(request, output) + } + + val buffered = mutableListOf() + var bufferedBytes = 0L + val usage = try { + search.complete( + request, + ProviderOutput { bytes -> + bufferedBytes = Math.addExact(bufferedBytes, bytes.size.toLong()) + if (bufferedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) { + throw DeepSeekProviderException("DeepSeek search output exceeded the gateway limit") + } + buffered += bytes + }, + ) + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + LOG.warn( + "DeepSeek search path failed requestId={} taskKind={} searchMode={} failureType={}", + request.requestId, + request.executionPolicy.taskKind.name, + request.executionPolicy.webSearch.name, + failure::class.simpleName ?: "Exception", + ) + return fallback.complete( + request.copy( + executionPolicy = request.executionPolicy.copy( + webSearch = GatewayWebSearchMode.DISABLED, + ), + ), + output, + ) + } + + buffered.forEach { output.emit(it) } + return usage + } + + private companion object { + val LOG = LoggerFactory.getLogger(DeepSeekSearchFallbackClient::class.java) + } +} + +internal class KtorDeepSeekResponsesClient( + private val client: HttpClient, + private val config: DeepSeekConfig, + private val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + }, + private val credentialResolver: ProviderApiKeyResolver = + StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey), +) : DeepSeekClient { + override suspend fun complete( + request: TextProviderRequest, + output: ProviderOutput, + ): ProviderUsage { + val searchMode = request.executionPolicy.webSearch + require(searchMode != GatewayWebSearchMode.DISABLED) { + "Responses search requires an enabled server search policy" + } + val reasoningEffort = requireNotNull(request.executionPolicy.reasoningEffort) { + "Responses search requires explicit reasoning effort" + } + val apiKey = credentialResolver.resolve(GatewayCredentialProvider.DEEPSEEK) + ?: throw DeepSeekConfigurationException("DeepSeek API key is not configured") + val payload = DeepSeekResponsesRequest( + model = config.modelFor(request.executionPolicy.modelProfile), + instructions = deepSeekSystemInstruction(request), + input = listOf(DeepSeekResponsesMessage("user", deepSeekUserText(request))), + tools = listOf(DeepSeekResponsesTool("web_search")), + toolChoice = when (searchMode) { + GatewayWebSearchMode.ALLOWED -> JsonPrimitive("auto") + GatewayWebSearchMode.REQUIRED -> buildJsonObject { put("type", "web_search") } + GatewayWebSearchMode.DISABLED -> error("Search policy changed during request construction") + }, + maxOutputTokens = request.maxOutputTokens, + reasoning = DeepSeekResponsesReasoning(reasoningEffort.name.lowercase()), + ) + + return client.preparePost("${config.endpoint.trimEnd('/')}/responses") { + bearerAuth(apiKey) + contentType(ContentType.Application.Json) + header(HttpHeaders.Accept, ContentType.Application.Json) + header("X-Request-ID", request.requestId) + setBody(payload) + }.execute { response -> + if (!response.status.isSuccess()) { + runCatching { response.body().readBounded() } + throw DeepSeekProviderException( + "DeepSeek Responses returned HTTP ${response.status.value}", + ) + } + val responseContentType = response.headers[HttpHeaders.ContentType] + ?.let { runCatching { ContentType.parse(it) }.getOrNull() } + if (responseContentType?.match(ContentType.Application.Json) != true) { + runCatching { response.body().readBounded() } + throw DeepSeekProviderException( + "DeepSeek Responses returned an unexpected content type", + ) + } + + val result = parseResponse(response.body().readBounded()) + if (searchMode == GatewayWebSearchMode.REQUIRED && !result.webSearchUsed) { + throw DeepSeekProviderException("DeepSeek omitted required web search") + } + emitCompatibleResponse(request, result, output) + result.usage + } + } + + private fun parseResponse(bytes: ByteArray): DeepSeekResponsesResult { + val root = runCatching { json.parseToJsonElement(bytes.decodeToString()).jsonObject } + .getOrElse { throw DeepSeekProviderException("DeepSeek returned malformed Responses JSON") } + if (root["error"] != null && root["error"] !is JsonNull) { + throw DeepSeekProviderException("DeepSeek Responses returned an error") + } + val output = runCatching { root["output"]?.jsonArray ?: emptyList() } + .getOrElse { throw DeepSeekProviderException("DeepSeek returned invalid Responses output") } + val topLevelText = runCatching { + root["output_text"]?.jsonPrimitive?.takeIf { it.isString }?.content + }.getOrNull() + val messageText = buildString { + output.forEach { itemElement -> + val item = runCatching { itemElement.jsonObject }.getOrNull() ?: return@forEach + if (item.string("type") != "message") return@forEach + val content = runCatching { item["content"]?.jsonArray ?: emptyList() } + .getOrElse { + throw DeepSeekProviderException("DeepSeek returned invalid message content") + } + content.forEach { partElement -> + val part = runCatching { partElement.jsonObject }.getOrNull() ?: return@forEach + if (part.string("type") in RESPONSE_TEXT_TYPES) { + part.string("text")?.let(::append) + } + } + } + } + val text = topLevelText?.takeIf(String::isNotBlank) ?: messageText + if (text.isBlank()) { + throw DeepSeekProviderException("DeepSeek Responses omitted output text") + } + val webSearchUsed = output.any { item -> + runCatching { item.jsonObject.string("type") == "web_search_call" }.getOrDefault(false) + } + val usageObject = runCatching { root["usage"]?.jsonObject } + .getOrNull() + ?: throw DeepSeekProviderException("DeepSeek Responses omitted token usage") + val inputTokens = usageObject.long("input_tokens") + ?: throw DeepSeekProviderException("DeepSeek Responses omitted input token usage") + val outputTokens = usageObject.long("output_tokens") + ?: throw DeepSeekProviderException("DeepSeek Responses omitted output token usage") + val computedTotal = runCatching { Math.addExact(inputTokens, outputTokens) } + .getOrElse { throw DeepSeekUsageException("DeepSeek Responses token usage overflowed") } + val totalTokens = usageObject.long("total_tokens") ?: computedTotal + if (inputTokens < 0 || outputTokens < 0 || totalTokens != computedTotal) { + throw DeepSeekUsageException("DeepSeek Responses returned inconsistent token usage") + } + + return DeepSeekResponsesResult( + text = text, + webSearchUsed = webSearchUsed, + usage = ProviderUsage( + meter = UsageMeter.LLM_TOKEN, + units = totalTokens, + inputUnits = inputTokens, + outputUnits = outputTokens, + ), + ) + } + + private suspend fun emitCompatibleResponse( + request: TextProviderRequest, + result: DeepSeekResponsesResult, + output: ProviderOutput, + ) { + if (!request.stream) { + output.emit(bufferedChatPayload(result).encodeToByteArray()) + return + } + val content = buildJsonObject { + put( + "choices", + buildJsonArray { + add( + buildJsonObject { + put( + "delta", + buildJsonObject { put("content", result.text) }, + ) + }, + ) + }, + ) + } + val terminal = buildJsonObject { + put( + "choices", + buildJsonArray { + add( + buildJsonObject { + put("delta", buildJsonObject {}) + put("finish_reason", "stop") + }, + ) + }, + ) + } + val usage = buildJsonObject { + put("choices", buildJsonArray {}) + put("usage", usageJson(result.usage)) + } + output.emit("data: $content\n\n".encodeToByteArray()) + output.emit("data: $terminal\n\n".encodeToByteArray()) + output.emit("data: $usage\n\n".encodeToByteArray()) + output.emit("data: [DONE]\n\n".encodeToByteArray()) + } + + private fun bufferedChatPayload(result: DeepSeekResponsesResult): String = + buildJsonObject { + put( + "choices", + buildJsonArray { + add( + buildJsonObject { + put( + "message", + buildJsonObject { put("content", result.text) }, + ) + }, + ) + }, + ) + put("usage", usageJson(result.usage)) + }.toString() + + private fun usageJson(usage: ProviderUsage): JsonObject = + buildJsonObject { + put("prompt_tokens", requireNotNull(usage.inputUnits)) + put("completion_tokens", requireNotNull(usage.outputUnits)) + put("total_tokens", usage.units) + } + + private suspend fun ByteReadChannel.readBounded(): ByteArray { + val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L) + .readByteArray() + if (bytes.size > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) { + throw DeepSeekProviderException("DeepSeek Responses exceeded the gateway limit") + } + return bytes + } + + private companion object { + val RESPONSE_TEXT_TYPES = setOf("output_text", "text") + } +} + +private fun JsonObject.string(name: String): String? = + runCatching { + get(name)?.jsonPrimitive?.takeIf { it.isString }?.content + }.getOrNull() + +private fun JsonObject.long(name: String): Long? = + runCatching { get(name)?.jsonPrimitive?.longOrNull }.getOrNull() + +private data class DeepSeekResponsesResult( + val text: String, + val webSearchUsed: Boolean, + val usage: ProviderUsage, +) + +@Serializable +private data class DeepSeekResponsesRequest( + val model: String, + val instructions: String, + val input: List, + val tools: List, + @SerialName("tool_choice") + val toolChoice: JsonElement, + @SerialName("max_output_tokens") + val maxOutputTokens: Int, + val reasoning: DeepSeekResponsesReasoning, +) + +@Serializable +private data class DeepSeekResponsesMessage( + val role: String, + val content: String, +) + +@Serializable +private data class DeepSeekResponsesTool( + val type: String, +) + +@Serializable +private data class DeepSeekResponsesReasoning( + val effort: String, +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt index 8eb88ed..ae08fd2 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt @@ -1,5 +1,8 @@ package com.osglab.account.features.gateway.providers.volcengine +import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider +import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver +import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver import com.osglab.account.features.gateway.models.AsrGatewayOptions import com.osglab.account.features.gateway.models.AsrProviderRequest import com.osglab.account.features.gateway.models.GatewayCapability @@ -103,6 +106,8 @@ class KtorVolcengineAsrTransport( ignoreUnknownKeys = true explicitNulls = false }, + private val credentialResolver: ProviderApiKeyResolver = + StaticProviderApiKeyResolver(volcengineApiKey = config.apiKey), ) : VolcengineAsrTransport, VolcengineStreamingClient { override suspend fun transcribe( request: AsrProviderRequest, @@ -131,6 +136,9 @@ class KtorVolcengineAsrTransport( var outputBytes = 0L var frameCount = 0 val sequenceValidator = SaucSequenceValidator() + // Resolve once per connection so an in-flight stream keeps the key it started with. + val apiKey = credentialResolver.resolve(GatewayCredentialProvider.VOLCENGINE) + ?.takeIf(String::isNotBlank) withTimeout(config.responseTimeoutMillis) { client.webSocket( @@ -140,7 +148,6 @@ class KtorVolcengineAsrTransport( headers.append("X-Api-Request-Id", providerRequestId) headers.append("X-Api-Connect-Id", providerRequestId) headers.append("X-Api-Sequence", "-1") - val apiKey = config.apiKey?.takeIf(String::isNotBlank) if (apiKey != null) { headers.append("X-Api-Key", apiKey) } else { diff --git a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt index 28a88ae..cd5cd5f 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt @@ -458,7 +458,7 @@ private suspend fun ApplicationCall.respondGatewayFailure( is OobeFeatureAlreadyUsedException -> respondGatewayError( HttpStatusCode.Conflict, "oobe_feature_already_used", - "This OOBE feature has already been used successfully", + "This OOBE feature has already been used successfully in this session", requestId, ) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt index 72661a1..5da70f4 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt @@ -61,12 +61,21 @@ class GatewayTaskPolicyResolver( GatewayTaskKind.AI_QUESTION -> reasoningPolicy( taskKind = taskKind, effort = config.aiReasoningEffort, + webSearch = GatewayWebSearchMode.ALLOWED, + maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens), + ) + + GatewayTaskKind.CURRENT_INFORMATION_QUESTION -> reasoningPolicy( + taskKind = taskKind, + effort = config.aiReasoningEffort, + webSearch = GatewayWebSearchMode.REQUIRED, maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens), ) GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy( taskKind = taskKind, effort = config.agentReasoningEffort, + webSearch = GatewayWebSearchMode.DISABLED, maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens), ) } @@ -89,13 +98,14 @@ class GatewayTaskPolicyResolver( private fun reasoningPolicy( taskKind: GatewayTaskKind, effort: GatewayReasoningEffort, + webSearch: GatewayWebSearchMode, maxOutputTokens: Int, ) = GatewayTaskExecutionPolicy( taskKind = taskKind, modelProfile = GatewayModelProfile.REASONING, thinking = GatewayThinkingMode.ENABLED, reasoningEffort = effort, - webSearch = GatewayWebSearchMode.DISABLED, + webSearch = webSearch, tools = GatewayToolsMode.DISABLED, allowEmptyContentRetry = true, maxOutputTokens = maxOutputTokens, @@ -125,6 +135,7 @@ class GatewayTaskPolicyResolver( ) val AI_TASKS = setOf( GatewayTaskKind.AI_QUESTION, + GatewayTaskKind.CURRENT_INFORMATION_QUESTION, GatewayTaskKind.CLIPBOARD_TRANSFORM, GatewayTaskKind.CUSTOM_SKILL, ) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt index 6826559..ddc5546 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.gateway.services import com.osglab.account.features.gateway.models.AsrProviderRequest +import com.osglab.account.features.gateway.models.GatewayWebSearchMode import com.osglab.account.features.gateway.models.ProviderRequest import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.models.UsageMeter @@ -29,10 +30,18 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator { // covers server-controlled system messages and chat framing. val inputBytes = request.input.encodeToByteArray().size.toLong() val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L - val input = Math.addExact( + val requestInput = Math.addExact( Math.addExact(inputBytes, contextBytes), LLM_PROMPT_OVERHEAD_TOKENS, ) + // Responses web search injects provider-controlled result context that + // is included in input-token usage. Reserve a bounded allowance so + // settlement remains token-based without charging a separate search fee. + val input = if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) { + requestInput + } else { + Math.addExact(requestInput, WEB_SEARCH_INPUT_TOKEN_ALLOWANCE) + } val output = request.maxOutputTokens.toLong() return ProviderUsageEstimate( meter = UsageMeter.LLM_TOKEN, @@ -43,4 +52,5 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator { } private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L + private const val WEB_SEARCH_INPUT_TOKEN_ALLOWANCE = 32_000L } diff --git a/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt b/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt index 1f1952d..7c2e879 100644 --- a/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/oobe/ExposedOobeRepository.kt @@ -52,6 +52,7 @@ private object OobeRefreshTokensTable : Table("oobe_gateway_refresh_tokens") { } private object OobeClaimsTable : Table("oobe_gateway_claims") { + val grantId = varchar("grant_id", 36) val subjectId = varchar("subject_id", 36) val feature = varchar("feature", 32) val requestId = varchar("request_id", 64) @@ -59,7 +60,7 @@ private object OobeClaimsTable : Table("oobe_gateway_claims") { val expiresAt = timestamp("expires_at") val createdAt = timestamp("created_at") val updatedAt = timestamp("updated_at") - override val primaryKey = PrimaryKey(subjectId, feature) + override val primaryKey = PrimaryKey(grantId, feature) } private object OobeProviderRequestsTable : Table("oobe_provider_requests") { @@ -236,7 +237,7 @@ class ExposedOobeRepository( expiresAt: Instant, now: Instant, ): OobeRequestClaim? = databaseFactory.query { - val key = claimKey(request.subjectId, request.feature.name) + val key = claimKey(request.grantId, request.feature.name) val reclaimed = OobeClaimsTable.update({ key and (OobeClaimsTable.status eq CLAIMED) and @@ -247,6 +248,7 @@ class ExposedOobeRepository( it[updatedAt] = now } == 1 val inserted = !reclaimed && OobeClaimsTable.insertIgnore { + it[grantId] = request.grantId it[subjectId] = request.subjectId it[feature] = request.feature.name it[requestId] = request.requestId @@ -269,7 +271,7 @@ class ExposedOobeRepository( it[createdAt] = now }.insertedCount == 1 if (!auditInserted) throw OobeRequestAlreadyClaimedException() - OobeRequestClaim(request.subjectId, request.feature, request.requestId) + OobeRequestClaim(request.subjectId, request.grantId, request.feature, request.requestId) } override suspend fun markStarted(claim: OobeRequestClaim) { @@ -280,7 +282,7 @@ class ExposedOobeRepository( databaseFactory.query { val now = clock.instant() val claimChanged = OobeClaimsTable.update({ - claimKey(claim.subjectId, claim.feature.name) and + claimKey(claim.grantId, claim.feature.name) and (OobeClaimsTable.requestId eq claim.requestId) and (OobeClaimsTable.status eq CLAIMED) }) { @@ -308,7 +310,7 @@ class ExposedOobeRepository( override suspend fun release(claim: OobeRequestClaim, errorCode: String) { databaseFactory.query { OobeClaimsTable.deleteWhere { - claimKey(claim.subjectId, claim.feature.name) and + claimKey(claim.grantId, claim.feature.name) and (OobeClaimsTable.requestId eq claim.requestId) and (OobeClaimsTable.status eq CLAIMED) } @@ -332,7 +334,7 @@ class ExposedOobeRepository( // Fail closed: an uncertain provider outcome must never become // reclaimable after the temporary claim TTL. OobeClaimsTable.update({ - claimKey(claim.subjectId, claim.feature.name) and + claimKey(claim.grantId, claim.feature.name) and (OobeClaimsTable.requestId eq claim.requestId) and (OobeClaimsTable.status eq CLAIMED) }) { @@ -377,8 +379,8 @@ private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeG expiresAt = this[OobeRefreshTokensTable.expiresAt], ) -private fun claimKey(subjectId: String, feature: String) = - (OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature) +private fun claimKey(grantId: String, feature: String) = + (OobeClaimsTable.grantId eq grantId) and (OobeClaimsTable.feature eq feature) private fun requestKey(claim: OobeRequestClaim) = (OobeProviderRequestsTable.subjectId eq claim.subjectId) and diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt index 31700e8..cac20a2 100644 --- a/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeModels.kt @@ -103,6 +103,7 @@ sealed interface OobeRefreshRotationResult { data class OobeRequestClaim( val subjectId: String, + val grantId: String, val feature: OobeFeature, val requestId: String, ) diff --git a/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt b/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt index 7b4d0e9..7c14209 100644 --- a/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/oobe/OobeRepository.kt @@ -39,4 +39,4 @@ class OobeRequestAlreadyClaimedException : RuntimeException("The OOBE provider request ID has already been used") class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) : - RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used") + RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used in this grant") diff --git a/src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql b/src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql new file mode 100644 index 0000000..0cc1866 --- /dev/null +++ b/src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql @@ -0,0 +1,23 @@ +DROP TABLE oobe_gateway_claims; + +CREATE TABLE oobe_gateway_claims ( + grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (grant_id, feature), + INDEX idx_oobe_claim_subject (subject_id), + INDEX idx_oobe_claim_expiry (status, expires_at), + CONSTRAINT fk_oobe_claim_grant + FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE, + CONSTRAINT fk_oobe_claim_subject + FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE, + CONSTRAINT chk_oobe_claim_feature + CHECK (feature IN ('VOICE_INPUT', 'CLIPBOARD_TRANSLATE', 'CLIPBOARD_REPLY', 'ASK_AI')), + CONSTRAINT chk_oobe_claim_status + CHECK (status IN ('CLAIMED', 'CONSUMED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/migration/V28__runtime_provider_api_keys.sql b/src/main/resources/db/migration/V28__runtime_provider_api_keys.sql new file mode 100644 index 0000000..624fdbd --- /dev/null +++ b/src/main/resources/db/migration/V28__runtime_provider_api_keys.sql @@ -0,0 +1,13 @@ +CREATE TABLE gateway_provider_credentials ( + provider_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + encrypted_api_key TEXT NOT NULL, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_by_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (provider_id), + CONSTRAINT fk_gateway_provider_credentials_operator + FOREIGN KEY (updated_by_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT, + CONSTRAINT chk_gateway_provider_credentials_provider + CHECK (provider_id IN ('deepseek', 'volcengine')), + CONSTRAINT chk_gateway_provider_credentials_ciphertext + CHECK (CHAR_LENGTH(encrypted_api_key) > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index 90f105f..6cb4485 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -238,6 +238,20 @@ class DeploymentConsistencyTest : FunSpec({ keyboardSchema shouldNotContain "hostApplication" } + test("anonymous OOBE feature claims are scoped to each guided session") { + val migration = root.read( + "src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql", + ) + val openApi = root.read("docs/openapi.yaml") + + migration shouldContain "PRIMARY KEY (grant_id, feature)" + migration shouldContain + "FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE" + migration shouldNotContain "PRIMARY KEY (subject_id, feature)" + openApi shouldContain "Each feature can" + openApi shouldContain "succeed once within this short-lived grant" + } + test("production Compose reuses private MySQL and hardens the application container") { val compose = root.read("compose.yaml") @@ -404,6 +418,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf( "/v1/admin/auth/session", "/v1/admin/auth/login", "/v1/admin/auth/logout", + "/v1/admin/providers", + "/v1/admin/providers/{providerId}/api-key", "/v1/admin/overview", "/v1/admin/referrals", "/v1/admin/analytics", diff --git a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt index b457da8..d637153 100644 --- a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt +++ b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt @@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({ runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/" runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/" - runner shouldContain "Flyway history was not exactly successful V1-V26" + runner shouldContain "Flyway history was not exactly successful V1-V27" runner shouldContain "default referral rewards were not 1000 credits for both accounts" runner shouldContain "active smaller credit rates did not match the V10 contract" runner shouldContain "first ledger page omitted nextCursor" diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index 9c0b54b..151519e 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -25,12 +25,18 @@ import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.gateway.credentials.GatewayCredentialService +import com.osglab.account.features.gateway.credentials.GatewayCredentialSource +import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus +import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain import io.ktor.client.statement.bodyAsText import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HttpHeaders @@ -482,6 +488,74 @@ class AdminRoutesTest { assertEquals(HttpStatusCode.BadRequest, response.status) response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR"""" } + + @Test + fun `provider status is super admin only and never exposes API keys`() = testApplication { + val credentialService = mockk() + coEvery { credentialService.listStatuses() } returns listOf( + GatewayCredentialStatus( + providerId = "deepseek", + configured = true, + source = GatewayCredentialSource.RUNTIME_OVERRIDE, + updatedAt = "2026-08-22T08:00:00Z", + ), + ) + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + credentialService = credentialService, + ) + } + + val response = client.get("/v1/admin/providers") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + response.status shouldBe HttpStatusCode.OK + val body = response.bodyAsText() + body shouldContain """"providerId":"deepseek"""" + body shouldContain """"source":"RUNTIME_OVERRIDE"""" + body shouldNotContain "apiKey" + body shouldNotContain "secret-runtime-key" + } + + @Test + fun `non super admin cannot update provider API keys`() = testApplication { + val credentialService = mockk(relaxed = true) + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPPORT), + credentialService = credentialService, + ) + } + + val response = client.putProviderKey("deepseek", "new-secret") + + response.status shouldBe HttpStatusCode.Forbidden + response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION"""" + coVerify(exactly = 0) { credentialService.updateApiKey(any(), any(), any(), any()) } + } + + @Test + fun `provider API key validation maps to stable error without echoing input`() = testApplication { + val credentialService = mockk() + coEvery { + credentialService.updateApiKey(any(), any(), any(), any()) + } throws InvalidProviderApiKeyException() + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + credentialService = credentialService, + ) + } + + val response = client.putProviderKey("volcengine", "invalid-secret") + + response.status shouldBe HttpStatusCode.BadRequest + response.bodyAsText() shouldBe """{"code":"VALIDATION_ERROR"}""" + response.bodyAsText() shouldNotContain "invalid-secret" + } } private fun io.ktor.server.application.Application.installAdminTestRoutes( @@ -491,6 +565,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes( operatorService: AdminOperatorService = mockk(relaxed = true), auditService: AdminAuditService = mockk(relaxed = true), usersService: AdminUsersService = mockk(relaxed = true), + credentialService: GatewayCredentialService? = null, mtlsRequired: Boolean = true, ) { install(ContentNegotiation) { @@ -513,6 +588,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes( grantService = grantService, operatorService = operatorService, auditService = auditService, + credentialService = credentialService, ) } } @@ -568,3 +644,14 @@ private suspend fun io.ktor.client.HttpClient.postGrant() = """{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""", ) } + +private suspend fun io.ktor.client.HttpClient.putProviderKey(providerId: String, apiKey: String) = + put("/v1/admin/providers/$providerId/api-key") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + header("X-Request-ID", "provider-key-update") + contentType(ContentType.Application.Json) + setBody("""{"apiKey":"$apiKey"}""") + } diff --git a/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepositoryIntegrationTest.kt new file mode 100644 index 0000000..e112d44 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialRepositoryIntegrationTest.kt @@ -0,0 +1,121 @@ +package com.osglab.account.features.gateway.credentials + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.NewAdminOperator +import com.osglab.account.features.admin.repositories.ExposedAdminRepository +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer +import java.sql.DriverManager +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID + +class GatewayCredentialRepositoryIntegrationTest : FunSpec({ + test("MySQL stores encrypted overrides and audit in the same mutation") { + withCredentialDatabase { config, databaseFactory -> + val now = Instant.parse("2026-08-22T08:00:00Z") + val operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val adminRepository = ExposedAdminRepository(databaseFactory) + adminRepository.createOperatorIfAbsent( + NewAdminOperator( + id = operatorId, + normalizedUsername = "credential-owner", + passwordHash = "password-hash", + encryptedTotpSecret = "encrypted-totp", + role = AdminRole.SUPER_ADMIN, + createdAt = now, + ), + ) + val repository = ExposedGatewayCredentialRepository(databaseFactory) + val encryptor = FieldEncryptor(ByteArray(32) { it.toByte() }) + val resolver = DatabaseProviderApiKeyResolver( + repository, + encryptor, + EnvironmentProviderCredentials("environment-key", null, false), + ) + val service = GatewayCredentialService( + repository, + resolver, + encryptor, + Clock.fixed(now, ZoneOffset.UTC), + ) + + service.updateApiKey( + GatewayCredentialProvider.DEEPSEEK, + "database-secret-key", + operatorId, + "credential-request", + ) + + resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "database-secret-key" + val rawCiphertext = DriverManager.getConnection( + config.jdbcUrl, + config.username, + config.password, + ).use { connection -> + connection.prepareStatement( + "SELECT encrypted_api_key FROM gateway_provider_credentials WHERE provider_id = ?", + ).use { statement -> + statement.setString(1, "deepseek") + statement.executeQuery().use { result -> + check(result.next()) + result.getString(1) + } + } + } + rawCiphertext shouldNotContain "database-secret-key" + adminRepository.listAudit(10).single { + it.action == AdminAuditAction.PROVIDER_API_KEY_UPDATED + }.run { + targetId shouldBe "deepseek" + requestId shouldBe "credential-request" + } + } + } +}) + +private suspend fun withCredentialDatabase( + block: suspend (DatabaseConfig, DatabaseFactory) -> Unit, +) { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + val mysql = if (externalJdbcUrl == null) { + CredentialMySqlContainer("mysql:8.4") + .withDatabaseName("osg_gateway_credential_test") + .withUsername("test") + .withPassword("test") + .also(CredentialMySqlContainer::start) + } else { + null + } + val config = DatabaseConfig( + jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl, + username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root", + password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "", + maximumPoolSize = 4, + ) + val databaseFactory = DatabaseFactory(config) + try { + databaseFactory.database + block(config, databaseFactory) + } finally { + databaseFactory.close() + mysql?.stop() + } +} + +private class CredentialMySqlContainer(image: String) : + MySQLContainer(image) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt new file mode 100644 index 0000000..7487798 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt @@ -0,0 +1,136 @@ +package com.osglab.account.features.gateway.credentials + +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.features.admin.models.NewAdminAuditEvent +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID + +class GatewayCredentialServiceTest : StringSpec({ + val encryptionKey = ByteArray(32) { it.toByte() } + val operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111") + + "uses environment credentials when no runtime override exists" { + val repository = InMemoryGatewayCredentialRepository() + val resolver = DatabaseProviderApiKeyResolver( + repository, + FieldEncryptor(encryptionKey), + EnvironmentProviderCredentials( + deepSeekApiKey = "environment-deepseek", + volcengineApiKey = null, + volcengineLegacyConfigured = true, + ), + ) + + resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "environment-deepseek" + resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe null + resolver.status(GatewayCredentialProvider.VOLCENGINE) shouldBe GatewayCredentialStatus( + providerId = "volcengine", + configured = true, + source = GatewayCredentialSource.ENVIRONMENT, + ) + } + + "runtime override takes priority and decrypts only at resolution time" { + val repository = InMemoryGatewayCredentialRepository() + val encryptor = FieldEncryptor(encryptionKey) + repository.credentialOverride = ProviderApiKeyOverride( + provider = GatewayCredentialProvider.VOLCENGINE, + encryptedApiKey = encryptor.encrypt( + "runtime-volcengine", + encryptionContext(GatewayCredentialProvider.VOLCENGINE), + ), + updatedAt = Instant.parse("2026-08-22T08:00:00Z"), + updatedByOperatorId = operatorId, + ) + val resolver = DatabaseProviderApiKeyResolver( + repository, + encryptor, + EnvironmentProviderCredentials(null, "environment-volcengine", true), + ) + + resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine" + resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe + GatewayCredentialSource.RUNTIME_OVERRIDE + } + + "two updates make new resolutions use the latest encrypted key" { + val repository = InMemoryGatewayCredentialRepository() + val encryptor = FieldEncryptor(encryptionKey) + val resolver = DatabaseProviderApiKeyResolver( + repository, + encryptor, + EnvironmentProviderCredentials("environment-deepseek", null, false), + ) + val service = GatewayCredentialService( + repository, + resolver, + encryptor, + Clock.fixed(Instant.parse("2026-08-22T08:00:00Z"), ZoneOffset.UTC), + ) + + service.updateApiKey( + GatewayCredentialProvider.DEEPSEEK, + " first-runtime-key ", + operatorId, + "request-one", + ) + resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "first-runtime-key" + repository.credentialOverride!!.encryptedApiKey shouldNotContain "first-runtime-key" + + service.updateApiKey( + GatewayCredentialProvider.DEEPSEEK, + "second-runtime-key", + operatorId, + "request-two", + ) + resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "second-runtime-key" + repository.auditEvent!!.targetId shouldBe "deepseek" + repository.auditEvent!!.requestId shouldBe "request-two" + } + + "rejects blank multiline and oversized API keys" { + val repository = InMemoryGatewayCredentialRepository() + val encryptor = FieldEncryptor(encryptionKey) + val resolver = DatabaseProviderApiKeyResolver( + repository, + encryptor, + EnvironmentProviderCredentials(null, null, false), + ) + val service = GatewayCredentialService(repository, resolver, encryptor) + + listOf(" ", "line-one\nline-two", "line-one\rline-two", "x".repeat(4_097)).forEach { + shouldThrow { + service.updateApiKey( + GatewayCredentialProvider.DEEPSEEK, + it, + operatorId, + null, + ) + } + } + repository.credentialOverride shouldBe null + } +}) + +private class InMemoryGatewayCredentialRepository : GatewayCredentialRepository { + var credentialOverride: ProviderApiKeyOverride? = null + var auditEvent: NewAdminAuditEvent? = null + + override suspend fun findOverride( + provider: GatewayCredentialProvider, + ): ProviderApiKeyOverride? = credentialOverride?.takeIf { it.provider == provider } + + override suspend fun upsertOverride( + credentialOverride: ProviderApiKeyOverride, + auditEvent: NewAdminAuditEvent, + ) { + this.credentialOverride = credentialOverride + this.auditEvent = auditEvent + } +} diff --git a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt index d6b78fc..2527141 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt @@ -63,6 +63,7 @@ class TextRequestPolicyTest : StringSpec({ "translation" to GatewayTaskKind.TRANSLATION, "edit_last_input" to GatewayTaskKind.EDIT_LAST_INPUT, "ai_question" to GatewayTaskKind.AI_QUESTION, + "current_information_question" to GatewayTaskKind.CURRENT_INFORMATION_QUESTION, "clipboard_transform" to GatewayTaskKind.CLIPBOARD_TRANSFORM, "custom_skill" to GatewayTaskKind.CUSTOM_SKILL, "agent_planning" to GatewayTaskKind.AGENT_PLANNING, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt index 22bf17e..a94b31b 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt @@ -1,12 +1,16 @@ package com.osglab.account.features.gateway.providers.deepseek import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider +import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver import com.osglab.account.features.gateway.models.GatewayTaskKind +import com.osglab.account.features.gateway.models.GatewayWebSearchMode import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.string.shouldContain import io.kotest.matchers.shouldBe import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -19,6 +23,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -133,6 +138,159 @@ class DeepSeekClientTest : StringSpec({ } } + "uses Responses web search and normalizes buffered output for ordinary AI" { + var requestBody = "" + var requestPath = "" + val emitted = mutableListOf() + val client = client( + """ + { + "output":[ + {"type":"web_search_call","id":"search-1"}, + {"type":"message","content":[{"type":"output_text","text":"今日热点"}]} + ], + "usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17} + } + """.trimIndent(), + onRequest = { requestBody = it }, + onPath = { requestPath = it }, + ) + try { + val usage = DeepSeekProvider(client, CONFIG).execute( + request(webSearch = GatewayWebSearchMode.ALLOWED), + ProviderOutput { emitted += it }, + ) + + requestPath shouldBe "/v1/responses" + val payload = Json.parseToJsonElement(requestBody).jsonObject + payload.getValue("tool_choice").jsonPrimitive.content shouldBe "auto" + payload.getValue("tools").jsonArray.first().jsonObject + .getValue("type").jsonPrimitive.content shouldBe "web_search" + usage shouldBe com.osglab.account.features.gateway.models.ProviderUsage( + meter = com.osglab.account.features.gateway.models.UsageMeter.LLM_TOKEN, + units = 17, + inputUnits = 12, + outputUnits = 5, + ) + val downstream = Json.parseToJsonElement( + emitted.joinToString("") { it.decodeToString() }, + ).jsonObject + downstream.getValue("choices").jsonArray.first().jsonObject + .getValue("message").jsonObject + .getValue("content").jsonPrimitive.content shouldBe "今日热点" + } finally { + client.close() + } + } + + "normalizes a searched Responses result into managed Chat Completions SSE" { + val emitted = mutableListOf() + val client = client( + """ + { + "output":[ + {"type":"web_search_call","id":"search-1"}, + {"type":"message","content":[{"type":"output_text","text":"最新结果"}]} + ], + "usage":{"input_tokens":14,"output_tokens":3,"total_tokens":17} + } + """.trimIndent(), + ) + try { + val usage = DeepSeekProvider(client, CONFIG).execute( + request(webSearch = GatewayWebSearchMode.ALLOWED).copy(stream = true), + ProviderOutput { emitted += it }, + ) + + val downstream = emitted.joinToString("") { it.decodeToString() } + downstream shouldContain """"content":"最新结果"""" + downstream shouldContain """"prompt_tokens":14""" + downstream shouldContain "data: [DONE]" + usage.units shouldBe 17 + } finally { + client.close() + } + } + + "forces web search for current-information questions" { + var requestBody = "" + val client = client( + """ + { + "output":[ + {"type":"web_search_call","id":"search-1"}, + {"type":"message","content":[{"type":"output_text","text":"verified"}]} + ], + "usage":{"input_tokens":8,"output_tokens":2,"total_tokens":10} + } + """.trimIndent(), + onRequest = { requestBody = it }, + ) + try { + DeepSeekProvider(client, CONFIG).execute( + request( + taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, + webSearch = GatewayWebSearchMode.REQUIRED, + ), + DISCARD_OUTPUT, + ) + + Json.parseToJsonElement(requestBody).jsonObject + .getValue("tool_choice").jsonObject + .getValue("type").jsonPrimitive.content shouldBe "web_search" + } finally { + client.close() + } + } + + "falls back to Chat Completions when Responses search fails" { + val paths = mutableListOf() + val requestBodies = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + requestBodies += request.body.toByteArray().decodeToString() + if (request.url.encodedPath.endsWith("/responses")) { + respond( + content = """{"error":{"message":"search unavailable"}}""", + status = HttpStatusCode.ServiceUnavailable, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } else { + respond( + content = + """{"choices":[{"message":{"content":"无法核实实时信息"}}],""" + + """"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13}}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + }, + ) { + install(ContentNegotiation) { + json(Json { explicitNulls = false }) + } + } + try { + val usage = DeepSeekProvider(client, CONFIG).execute( + request( + taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, + webSearch = GatewayWebSearchMode.REQUIRED, + ), + DISCARD_OUTPUT, + ) + + paths shouldBe listOf("/v1/responses", "/v1/chat/completions") + val fallbackSystem = Json.parseToJsonElement(requestBodies.last()).jsonObject + .getValue("messages").jsonArray.first().jsonObject + .getValue("content").jsonPrimitive.content + fallbackSystem shouldContain "could not be verified" + usage.units shouldBe 13 + } finally { + client.close() + } + } + "retries one buffered empty result and returns the successful retry" { var attempts = 0 val provider = DeepSeekProvider( @@ -300,14 +458,41 @@ class DeepSeekClientTest : StringSpec({ client.close() } } + + "resolves the bearer token separately for each new upstream request" { + val authorizationHeaders = mutableListOf() + var currentKey = "first-key" + val client = client( + """{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""", + onAuthorization = authorizationHeaders::add, + ) + val resolver = ProviderApiKeyResolver { provider -> + provider shouldBe GatewayCredentialProvider.DEEPSEEK + currentKey + } + try { + val upstream = KtorDeepSeekClient(client, CONFIG, credentialResolver = resolver) + upstream.complete(request(), DISCARD_OUTPUT) + currentKey = "second-key" + upstream.complete(request(), DISCARD_OUTPUT) + + authorizationHeaders shouldBe listOf("Bearer first-key", "Bearer second-key") + } finally { + client.close() + } + } }) private fun client( responseBody: String, contentType: ContentType = ContentType.Application.Json, onRequest: suspend (String) -> Unit = {}, + onPath: suspend (String) -> Unit = {}, + onAuthorization: suspend (String?) -> Unit = {}, ) = HttpClient( MockEngine { request -> + onPath(request.url.encodedPath) + onAuthorization(request.headers[HttpHeaders.Authorization]) onRequest(request.body.toByteArray().decodeToString()) respond( content = responseBody, @@ -324,8 +509,11 @@ private fun client( private fun request( capability: GatewayCapability = GatewayCapability.AI, taskKind: GatewayTaskKind? = null, + webSearch: GatewayWebSearchMode = GatewayWebSearchMode.DISABLED, ): TextProviderRequest { - val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32) + val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32).copy( + webSearch = webSearch, + ) return TextProviderRequest( requestId = "deepseek-request", capability = capability, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt index 7d12726..7c1f1e4 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt @@ -5,6 +5,7 @@ import com.osglab.account.features.gateway.models.GatewayPrincipal import com.osglab.account.features.gateway.models.GatewayRequestSource import com.osglab.account.features.gateway.models.GatewayTaskKind import com.osglab.account.features.gateway.models.GatewayThinkingMode +import com.osglab.account.features.gateway.models.GatewayWebSearchMode import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderRequest @@ -135,6 +136,26 @@ class GatewayRequestIdTest : StringSpec({ GatewayThinkingMode.DISABLED } } + + "requires search for an explicit current-information question" { + val provider = RequestIdProvider() + + testApplication { + application { gatewayTestApplication(provider) } + + val response = client.post("/v1/gateway/llm/ai") { + header("X-Request-ID", "current-info-task-123") + contentType(ContentType.Application.Json) + setBody("""{"input":"今天的热点","taskKind":"current_information_question"}""") + } + + response.status shouldBe HttpStatusCode.OK + provider.lastTextRequest?.executionPolicy?.taskKind shouldBe + GatewayTaskKind.CURRENT_INFORMATION_QUESTION + provider.lastTextRequest?.executionPolicy?.webSearch shouldBe + GatewayWebSearchMode.REQUIRED + } + } }) private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) { diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt index 4ec1ee0..4ff4df1 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt @@ -68,7 +68,7 @@ class GatewayServiceBillingTest : StringSpec({ credits.settled.shouldContainExactly(RESERVATION_ID to 21L) credits.released shouldBe emptyList() credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN - credits.lastEstimate?.inputUnits shouldBe 261L + credits.lastEstimate?.inputUnits shouldBe 261L + 32_000L credits.lastEstimate?.outputUnits shouldBe 32L } diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt index a2002d8..d24887c 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt @@ -44,20 +44,49 @@ class GatewayTaskPolicyResolverTest : StringSpec({ } } - "enables explicit high-effort reasoning only for question and agent tasks" { - listOf( - GatewayCapability.AI to GatewayTaskKind.AI_QUESTION, - GatewayCapability.AGENT to GatewayTaskKind.AGENT_PLANNING, - ).forEach { (capability, taskKind) -> - val policy = resolver.resolve(capability, taskKind, 512) + "allows model-selected search for ordinary AI questions" { + val policy = resolver.resolve( + GatewayCapability.AI, + GatewayTaskKind.AI_QUESTION, + 512, + ) - policy.modelProfile shouldBe GatewayModelProfile.REASONING - policy.thinking shouldBe GatewayThinkingMode.ENABLED - policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH - policy.webSearch shouldBe GatewayWebSearchMode.DISABLED - policy.tools shouldBe GatewayToolsMode.DISABLED - policy.allowEmptyContentRetry shouldBe true - } + policy.modelProfile shouldBe GatewayModelProfile.REASONING + policy.thinking shouldBe GatewayThinkingMode.ENABLED + policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH + policy.webSearch shouldBe GatewayWebSearchMode.ALLOWED + policy.tools shouldBe GatewayToolsMode.DISABLED + policy.allowEmptyContentRetry shouldBe true + } + + "requires search for explicitly time-sensitive AI questions" { + val policy = resolver.resolve( + GatewayCapability.AI, + GatewayTaskKind.CURRENT_INFORMATION_QUESTION, + 512, + ) + + policy.modelProfile shouldBe GatewayModelProfile.REASONING + policy.thinking shouldBe GatewayThinkingMode.ENABLED + policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH + policy.webSearch shouldBe GatewayWebSearchMode.REQUIRED + policy.tools shouldBe GatewayToolsMode.DISABLED + policy.allowEmptyContentRetry shouldBe true + } + + "keeps agent planning offline while retaining high-effort reasoning" { + val policy = resolver.resolve( + GatewayCapability.AGENT, + GatewayTaskKind.AGENT_PLANNING, + 512, + ) + + policy.modelProfile shouldBe GatewayModelProfile.REASONING + policy.thinking shouldBe GatewayThinkingMode.ENABLED + policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH + policy.webSearch shouldBe GatewayWebSearchMode.DISABLED + policy.tools shouldBe GatewayToolsMode.DISABLED + policy.allowEmptyContentRetry shouldBe true } "rejects capability and task mismatches without inspecting content" { diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimatorTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimatorTest.kt new file mode 100644 index 0000000..9baeeeb --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimatorTest.kt @@ -0,0 +1,40 @@ +package com.osglab.account.features.gateway.services + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayTaskKind +import com.osglab.account.features.gateway.models.TextProviderRequest +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe + +class GatewayUsageEstimatorTest : StringSpec({ + "reserves provider-injected input tokens only for searchable tasks" { + val searchable = request(GatewayTaskKind.AI_QUESTION) + val offline = request(GatewayTaskKind.CLIPBOARD_TRANSFORM) + + val searchableEstimate = ConservativeGatewayUsageEstimator.estimate(searchable) + val offlineEstimate = ConservativeGatewayUsageEstimator.estimate(offline) + + searchableEstimate.inputUnits!! shouldBeGreaterThan offlineEstimate.inputUnits!! + searchableEstimate.inputUnits shouldBe offlineEstimate.inputUnits!! + 32_000L + searchableEstimate.outputUnits shouldBe offlineEstimate.outputUnits + } +}) + +private fun request(taskKind: GatewayTaskKind): TextProviderRequest { + val policy = GatewayTaskPolicyResolver().resolve( + capability = GatewayCapability.AI, + requestedTaskKind = taskKind, + requestedMaxOutputTokens = 32, + ) + return TextProviderRequest( + requestId = "usage-estimator-request", + capability = GatewayCapability.AI, + executionPolicy = policy, + input = "hello", + context = null, + maxOutputTokens = policy.maxOutputTokens, + temperature = 0.2, + stream = false, + ) +} diff --git a/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt b/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt index 9e8b5de..3b6a339 100644 --- a/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/oobe/OobeGatewayServiceTest.kt @@ -61,6 +61,23 @@ class OobeGatewayServiceTest : StringSpec({ credits.calls shouldBe 0 } + "allows the same page again under a new OOBE grant" { + val credits = CountingCredits() + val oobe = FakeOobeExecutionRepository() + val service = service(credits, oobe) + val feature = OobeFeature.VOICE_INPUT + + service.execute(OOBE_PRINCIPAL, request(feature, "oobe-first-session"), DISCARD) + service.execute( + OOBE_PRINCIPAL.copy(grantId = "30000000-0000-0000-0000-000000000002"), + request(feature, "oobe-replay-session"), + DISCARD, + ) + + oobe.consumed.map(OobeRequestClaim::grantId).toSet().size shouldBe 2 + credits.calls shouldBe 0 + } + "releases the feature claim when the provider fails" { val credits = CountingCredits() val oobe = FakeOobeExecutionRepository() @@ -154,7 +171,7 @@ private class CountingCredits : CreditReservationPort { } private class FakeOobeExecutionRepository : OobeRepository { - private val claimedFeatures = mutableSetOf() + private val claimedFeatures = mutableSetOf>() val consumed = mutableListOf() val released = mutableListOf() @@ -163,8 +180,9 @@ private class FakeOobeExecutionRepository : OobeRepository { expiresAt: Instant, now: Instant, ): OobeRequestClaim? { - if (!claimedFeatures.add(request.feature)) return null - return OobeRequestClaim(request.subjectId, request.feature, request.requestId) + val claimKey = request.grantId to request.feature + if (!claimedFeatures.add(claimKey)) return null + return OobeRequestClaim(request.subjectId, request.grantId, request.feature, request.requestId) } override suspend fun markStarted(claim: OobeRequestClaim) = Unit @@ -174,7 +192,7 @@ private class FakeOobeExecutionRepository : OobeRepository { } override suspend fun release(claim: OobeRequestClaim, errorCode: String) { - claimedFeatures -= claim.feature + claimedFeatures -= (claim.grantId to claim.feature) released += claim } diff --git a/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt index 46a1dd0..67d8e18 100644 --- a/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/osglab/account/features/oobe/OobeRepositoryIntegrationTest.kt @@ -27,7 +27,7 @@ import java.time.Instant import java.util.UUID class OobeRepositoryIntegrationTest : FunSpec({ - test("anonymous feature claim is atomic, consumed once, and independent from accounts") { + test("anonymous feature claim is atomic per grant and independent from accounts") { withOobeDatabase { config, databaseFactory -> val repository = ExposedOobeRepository(databaseFactory) val now = Instant.parse("2026-08-21T01:00:00Z") @@ -72,6 +72,27 @@ class OobeRepositoryIntegrationTest : FunSpec({ now, ) shouldBe null + val replayGrant = OobeGrant( + UUID.randomUUID().toString(), + subject.id, + now.plus(Duration.ofMinutes(30)), + ) + repository.createGrant( + NewOobeGrant( + grant = replayGrant, + refreshTokenId = UUID.randomUUID().toString(), + refreshFamilyId = UUID.randomUUID().toString(), + refreshTokenHash = "e".repeat(64), + refreshExpiresAt = replayGrant.expiresAt, + ), + now, + ) + repository.claim( + providerRequest(subject.id, replayGrant.id, "repeat-in-new-oobe-session"), + now.plus(Duration.ofMinutes(15)), + now, + ) shouldNotBe null + databaseCount(config, "accounts") shouldBe 0 databaseCount(config, "credit_ledger") shouldBe 0 databaseCount(config, "devicecheck_trial_claims") shouldBe 0