Add runtime provider controls and searchable AI routing
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
This commit is contained in:
Rocky
2026-08-22 16:33:17 +08:00
parent f8fa93dc48
commit 9fb947aa7d
43 changed files with 2079 additions and 80 deletions
+4 -2
View File
@@ -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; `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 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 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 high-effort thinking. Ordinary AI questions allow model-selected DeepSeek Responses web search;
implementation is configured. `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 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. them at runtime because this application does not read Docker `/run/secrets/*` files directly.
+20
View File
@@ -14,6 +14,9 @@ import type {
CreditGrantResponse, CreditGrantResponse,
LedgerQuery, LedgerQuery,
LedgerEntry, LedgerEntry,
ManagedProviderId,
ManagedProviderOverview,
ManagedProviderStatus,
OperatorsQuery, OperatorsQuery,
Overview, Overview,
PageResult, PageResult,
@@ -30,6 +33,7 @@ import type {
UpdateHintPackRequest, UpdateHintPackRequest,
UpdateHintFeedSettingsRequest, UpdateHintFeedSettingsRequest,
UpdateOfficialSkillRequest, UpdateOfficialSkillRequest,
UpdateProviderApiKeyRequest,
} from "./types"; } from "./types";
const API_BASE = "/v1/admin"; const API_BASE = "/v1/admin";
@@ -89,6 +93,8 @@ function safeMessage(status: number, code?: string): string {
HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新", HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新",
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求", HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用", HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
PROVIDER_API_KEY_INVALID: "API Key 不符合要求",
PROVIDER_NOT_FOUND: "不支持该 Provider",
RATE_LIMITED: "操作过于频繁,请稍后再试", RATE_LIMITED: "操作过于频繁,请稍后再试",
}; };
if (code && messages[code]) return messages[code]; if (code && messages[code]) return messages[code];
@@ -285,6 +291,20 @@ export const adminApi = {
headers: { "Idempotency-Key": payload.idempotencyKey }, headers: { "Idempotency-Key": payload.idempotencyKey },
}), }),
providers: () => request<ManagedProviderOverview>("/providers"),
updateProviderApiKey: (
providerId: ManagedProviderId,
payload: UpdateProviderApiKeyRequest,
) =>
request<ManagedProviderStatus>(
`/providers/${encodeURIComponent(providerId)}/api-key`,
{
method: "PUT",
body: JSON.stringify(payload),
},
),
auditLogs: (value?: string | AuditQuery) => auditLogs: (value?: string | AuditQuery) =>
request<PageResult<AuditLogEntry>>( request<PageResult<AuditLogEntry>>(
`/audit${encodeQuery(cursorQuery(value))}`, `/audit${encodeQuery(cursorQuery(value))}`,
+21 -1
View File
@@ -19,7 +19,8 @@ export type AdminAuditAction =
| "CONTENT_HINT_PACK_PUBLISHED" | "CONTENT_HINT_PACK_PUBLISHED"
| "CONTENT_HINT_PACK_SAVED" | "CONTENT_HINT_PACK_SAVED"
| "CONTENT_HINT_FEED_SETTINGS_UPDATED" | "CONTENT_HINT_FEED_SETTINGS_UPDATED"
| "CONTENT_HINT_FEED_GENERATED"; | "CONTENT_HINT_FEED_GENERATED"
| "PROVIDER_API_KEY_UPDATED";
export interface SkillLocalization { export interface SkillLocalization {
name: string; name: string;
@@ -123,6 +124,25 @@ export interface HintFeedGenerationResponse {
en: { version: number; cardCount: number }; 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 { export interface CursorPageQuery {
cursor?: string; cursor?: string;
limit?: number; limit?: number;
+14
View File
@@ -5,6 +5,7 @@ import {
ChevronRight, ChevronRight,
Coins, Coins,
GitBranch, GitBranch,
KeyRound,
LibraryBig, LibraryBig,
LogOut, LogOut,
Menu, Menu,
@@ -81,6 +82,11 @@ const SecurityPage = lazy(() =>
default: module.SecurityPage, default: module.SecurityPage,
})), })),
); );
const ProvidersPage = lazy(() =>
import("./features/providers/providers-page").then((module) => ({
default: module.ProvidersPage,
})),
);
interface NavItem { interface NavItem {
path: string; path: string;
@@ -150,6 +156,13 @@ const navigation: NavItem[] = [
icon: ShieldCheck, icon: ShieldCheck,
roles: ["SUPER_ADMIN"], roles: ["SUPER_ADMIN"],
}, },
{
path: "/providers",
label: "Provider 配置",
description: "上游密钥管理",
icon: KeyRound,
roles: ["SUPER_ADMIN"],
},
]; ];
export function App() { export function App() {
@@ -224,6 +237,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
<> <>
<Route path="/audit" element={<AuditPage />} /> <Route path="/audit" element={<AuditPage />} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/security" element={<SecurityPage />} />
<Route path="/providers" element={<ProvidersPage />} />
</> </>
) : null} ) : null}
<Route path="*" element={<Navigate to="/overview" replace />} /> <Route path="*" element={<Navigate to="/overview" replace />} />
@@ -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<ManagedProviderOverview>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
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 <LoadingState label="加载 Provider 配置" />;
if (error || !overview) return <ErrorState error={error} retry={() => void load()} />;
return (
<div className="space-y-6">
<PageHeader
eyebrow="Managed Providers"
title="Provider 配置"
description="替换托管网关使用的上游 API Key。新配置只影响之后开始的请求,现有密钥不会返回浏览器。"
actions={
<Button variant="secondary" onClick={() => void load()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
}
/>
<Card className="border-warning/25 bg-warning-soft/30 p-5 text-sm leading-6 text-foreground sm:p-6">
<div className="flex gap-3">
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-warning-soft text-warning">
<KeyRound className="size-4" aria-hidden />
</span>
<p>
Key Key
</p>
</div>
</Card>
<div className="grid gap-5 xl:grid-cols-2">
{PROVIDERS.map((provider) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
name={provider.name}
description={provider.description}
status={overview.providers.find((item) => item.providerId === provider.id)}
onUpdated={updateStatus}
/>
))}
</div>
</div>
);
}
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<HTMLFormElement>) {
event.preventDefault();
const normalized = apiKey.trim();
if (!validApiKey(normalized)) {
toast.error("API Key 必须为 1512 个字符,且不能包含换行");
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 (
<Card className="overflow-hidden">
<div className="border-b border-border bg-surface-muted/35 p-5 sm:p-6">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-primary-soft text-primary">
<ServerCog className="size-5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold">{name}</h2>
<Badge tone={status?.configured ? "success" : "danger"}>
{status?.configured ? "已配置" : "未配置"}
</Badge>
</div>
<p className="mt-1 text-sm leading-6 text-muted">{description}</p>
</div>
</div>
<dl className="mt-5 grid gap-3 text-sm sm:grid-cols-2">
<StatusItem label="当前来源" value={sourceLabel(status)} />
<StatusItem
label="最后替换"
value={status?.updatedAt ? formatDateTime(status.updatedAt) : "随服务器部署"}
/>
</dl>
</div>
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
<span className="mb-2 block">{name} API Key</span>
<Input
id={`${providerId}-api-key`}
type="password"
autoComplete="new-password"
maxLength={512}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={`输入新的 ${name} API Key`}
aria-describedby={`${providerId}-api-key-help`}
/>
</label>
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
Key
</p>
<Button type="submit" loading={saving} disabled={!apiKey.trim()}>
<KeyRound className="size-4" aria-hidden />
{name} API Key
</Button>
</form>
</Card>
);
}
function StatusItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-semibold text-muted">{label}</dt>
<dd className="mt-1 font-medium text-foreground">{value}</dd>
</div>
);
}
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")
);
}
+26
View File
@@ -276,4 +276,30 @@ describe("adminApi", () => {
); );
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP"); 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" }));
});
}); });
+84
View File
@@ -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(<App />);
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(<App />);
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",
},
],
};
}
+2 -2
View File
@@ -477,9 +477,9 @@ WHERE version IS NOT NULL
ORDER BY installed_rank; ORDER BY installed_rank;
SQL SQL
)" )"
EXPECTED_MIGRATIONS="$(seq 1 26 | awk '{ print $1 ":1" }')" EXPECTED_MIGRATIONS="$(seq 1 27 | awk '{ print $1 ":1" }')"
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] || [[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
fail "Flyway history was not exactly successful V1-V26" fail "Flyway history was not exactly successful V1-V27"
REFERRAL_REWARDS="$( REFERRAL_REWARDS="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL' mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits) SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
+2
View File
@@ -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_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_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.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.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_installations TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.product_analytics_events 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, 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_audit_log TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.admin_credit_grants 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 ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations
TO 'osg_smoke_runtime'@'%'; TO 'osg_smoke_runtime'@'%';
+3
View File
@@ -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_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_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.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.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_installations TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.product_analytics_events 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, 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_audit_log TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.admin_credit_grants 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 ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations
TO 'osg_account_runtime'@'10.20.%'; TO 'osg_account_runtime'@'10.20.%';
+85 -5
View File
@@ -387,7 +387,9 @@ paths:
summary: Create a short-lived anonymous OOBE gateway grant summary: Create a short-lived anonymous OOBE gateway grant
description: | description: |
Verifies an App Attest assertion bound to the installation and returns 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: requestBody:
required: true required: true
content: content:
@@ -477,11 +479,16 @@ paths:
The server deterministically selects model, thinking, search, tools, retry, The server deterministically selects model, thinking, search, tools, retry,
and output-budget policy from `capability` plus optional `taskKind`. It and output-budget policy from `capability` plus optional `taskKind`. It
never infers task type from `input` or `context`, and clients cannot 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 For account grants, `oobe` is accepted only for dictation polish and the
first successful request per account is complimentary. Anonymous OOBE first successful request per account is complimentary. Anonymous OOBE
grants require a matching `oobeFeature` and allow one successful request 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: parameters:
- $ref: "#/components/parameters/RequestId" - $ref: "#/components/parameters/RequestId"
- name: capability - name: capability
@@ -914,6 +921,63 @@ paths:
responses: responses:
"204": { description: Session revoked } "204": { description: Session revoked }
"401": { description: Session is invalid } "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: /v1/admin/overview:
get: get:
security: security:
@@ -1293,6 +1357,7 @@ paths:
- CONTENT_HINT_PACK_SAVED - CONTENT_HINT_PACK_SAVED
- CONTENT_HINT_FEED_SETTINGS_UPDATED - CONTENT_HINT_FEED_SETTINGS_UPDATED
- CONTENT_HINT_FEED_GENERATED - CONTENT_HINT_FEED_GENERATED
- PROVIDER_API_KEY_UPDATED
- name: result - name: result
in: query in: query
schema: { type: string, enum: [success, rejected] } schema: { type: string, enum: [success, rejected] }
@@ -1812,6 +1877,19 @@ components:
type: ["string", "null"] type: ["string", "null"]
enum: [SUPER_ADMIN, SUPPORT, ANALYST, null] enum: [SUPER_ADMIN, SUPPORT, ANALYST, null]
description: CSRF material is intentionally not reconstructed or returned by session checks. 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: AdminLoginResponse:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -2648,6 +2726,7 @@ components:
- translation - translation
- edit_last_input - edit_last_input
- ai_question - ai_question
- current_information_question
- clipboard_transform - clipboard_transform
- custom_skill - custom_skill
- agent_planning - agent_planning
@@ -2655,7 +2734,8 @@ components:
description: | description: |
Optional deterministic task selector. Allowed combinations are: Optional deterministic task selector. Allowed combinations are:
`polish` with `dictation_polish`, `translation`, or `edit_last_input`; `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 and `agent` with `agent_planning`. Omission defaults respectively to
`dictation_polish`, `ai_question`, and `agent_planning`. A mismatch `dictation_polish`, `ai_question`, and `agent_planning`. A mismatch
returns `400 invalid_request`. returns `400 invalid_request`.
@@ -2676,7 +2756,7 @@ components:
description: | description: |
Required for anonymous OOBE grants. The server validates that the Required for anonymous OOBE grants. The server validates that the
feature matches the requested capability and task kind, and allows 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: CreateOobeGrantRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -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.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.asr.AsrStreamingService 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.CreditReservationPort
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
@@ -272,7 +278,11 @@ fun Application.module() {
val providerConfig = appConfig.providers.volcengine.toProviderConfig() val providerConfig = appConfig.providers.volcengine.toProviderConfig()
AsrStreamingService( AsrStreamingService(
gateway = koin.get(), gateway = koin.get(),
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig), upstream = KtorVolcengineAsrTransport(
client = koin.get(),
config = providerConfig,
credentialResolver = koin.get(),
),
scope = this, scope = this,
) )
} else { } else {
@@ -367,6 +377,7 @@ fun Application.module() {
grantService = koin.get(), grantService = koin.get(),
operatorService = koin.get(), operatorService = koin.get(),
auditService = koin.get(), auditService = koin.get(),
credentialService = koin.get(),
contentService = koin.get(), contentService = koin.get(),
hintFeedService = koin.get(), hintFeedService = koin.get(),
) )
@@ -399,6 +410,19 @@ fun accountServerModule(config: AppConfig): Module = module {
single { SessionJwt(config.session) } single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) } single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) } single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<GatewayCredentialRepository> { 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<ProviderApiKeyResolver> { get<DatabaseProviderApiKeyResolver>() }
single { GatewayCredentialService(get(), get(), get()) }
single<AdminRepository> { ExposedAdminRepository(get()) } single<AdminRepository> { ExposedAdminRepository(get()) }
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() } single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
single<AdminTotpVerifier> { HmacTotpVerifier() } single<AdminTotpVerifier> { HmacTotpVerifier() }
@@ -667,7 +691,7 @@ fun accountServerModule(config: AppConfig): Module = module {
) )
} }
single { single {
ProviderCatalog(configuredProviders(config, get())) ProviderCatalog(configuredProviders(config, get(), get()))
} }
single { GatewayService(get(), get(), get(), get(), get(), get()) } single { GatewayService(get(), get(), get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) } single { GatewayReconciliationService(get(), get()) }
@@ -680,7 +704,11 @@ fun accountServerModule(config: AppConfig): Module = module {
} }
} }
private fun configuredProviders(config: AppConfig, client: HttpClient): List<GatewayProvider> = private fun configuredProviders(
config: AppConfig,
client: HttpClient,
credentialResolver: ProviderApiKeyResolver,
): List<GatewayProvider> =
buildList { buildList {
config.providers.deepSeek.apiKey?.let { apiKey -> config.providers.deepSeek.apiKey?.let { apiKey ->
add( add(
@@ -692,12 +720,21 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
model = config.providers.deepSeek.model, model = config.providers.deepSeek.model,
reasoningModel = config.providers.deepSeek.reasoningModel, reasoningModel = config.providers.deepSeek.reasoningModel,
), ),
credentialResolver = credentialResolver,
), ),
) )
} }
if (config.providers.volcengine.credentialsAvailable) { if (config.providers.volcengine.credentialsAvailable) {
val providerConfig = config.providers.volcengine.toProviderConfig() val providerConfig = config.providers.volcengine.toProviderConfig()
add(VolcengineAsrProvider(KtorVolcengineAsrTransport(client, providerConfig))) add(
VolcengineAsrProvider(
KtorVolcengineAsrTransport(
client = client,
config = providerConfig,
credentialResolver = credentialResolver,
),
),
)
} }
} }
@@ -119,6 +119,7 @@ enum class AdminAuditAction {
CONTENT_HINT_PACK_SAVED, CONTENT_HINT_PACK_SAVED,
CONTENT_HINT_FEED_SETTINGS_UPDATED, CONTENT_HINT_FEED_SETTINGS_UPDATED,
CONTENT_HINT_FEED_GENERATED, CONTENT_HINT_FEED_GENERATED,
PROVIDER_API_KEY_UPDATED,
} }
enum class AdminAuditOutcome { enum class AdminAuditOutcome {
@@ -47,6 +47,9 @@ import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.content.services.ContentService import com.osglab.account.features.content.services.ContentService
import com.osglab.account.features.content.feed.HintFeedService import com.osglab.account.features.content.feed.HintFeedService
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException
import io.ktor.http.Cookie import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -65,6 +68,7 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route import io.ktor.server.routing.route
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.time.Clock import java.time.Clock
@@ -93,6 +97,7 @@ fun Route.adminApiRoutes(
grantService: AdminGrantService, grantService: AdminGrantService,
operatorService: AdminOperatorService, operatorService: AdminOperatorService,
auditService: AdminAuditService, auditService: AdminAuditService,
credentialService: GatewayCredentialService? = null,
contentService: ContentService? = null, contentService: ContentService? = null,
hintFeedService: HintFeedService? = null, hintFeedService: HintFeedService? = null,
clock: Clock = Clock.systemUTC(), clock: Clock = Clock.systemUTC(),
@@ -174,6 +179,46 @@ fun Route.adminApiRoutes(
adminContentRoutes(config, sessionService, it, hintFeedService) adminContentRoutes(config, sessionService, it, hintFeedService)
} }
credentialService?.let { service ->
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<ProviderApiKeyUpdateRequest>() ?: 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") { get("/overview") {
if (call.requirePrincipal(config, sessionService) == null) return@get if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock) 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 @Serializable
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long) private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
@Serializable
private data class ProviderApiKeyUpdateRequest(val apiKey: String)
@Serializable @Serializable
private data class AdminOperatorCreateRequest( private data class AdminOperatorCreateRequest(
val username: String, val username: String,
@@ -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)"
}
@@ -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]),
)
@@ -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<GatewayCredentialStatus> =
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
}
}
@@ -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}"
@@ -109,6 +109,9 @@ enum class GatewayTaskKind {
@SerialName("ai_question") @SerialName("ai_question")
AI_QUESTION, AI_QUESTION,
@SerialName("current_information_question")
CURRENT_INFORMATION_QUESTION,
@SerialName("clipboard_transform") @SerialName("clipboard_transform")
CLIPBOARD_TRANSFORM, CLIPBOARD_TRANSFORM,
@@ -167,6 +170,12 @@ data class GatewayTaskExecutionPolicy(
) { ) {
"reasoning effort must be explicit exactly when thinking is enabled" "reasoning effort must be explicit exactly when thinking is enabled"
} }
require(
webSearch == GatewayWebSearchMode.DISABLED ||
thinking == GatewayThinkingMode.ENABLED,
) {
"web search requires an explicit thinking policy"
}
} }
} }
@@ -1,10 +1,14 @@
package com.osglab.account.features.gateway.providers.deepseek package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.agent.AgentPlan 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.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayModelProfile import com.osglab.account.features.gateway.models.GatewayModelProfile
import com.osglab.account.features.gateway.models.GatewayReasoningEffort 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.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode import com.osglab.account.features.gateway.models.GatewayToolsMode
import com.osglab.account.features.gateway.models.GatewayWebSearchMode import com.osglab.account.features.gateway.models.GatewayWebSearchMode
@@ -75,6 +79,19 @@ fun interface DeepSeekClient {
suspend fun complete(request: TextProviderRequest, output: ProviderOutput): ProviderUsage 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( class DeepSeekProvider(
private val upstream: DeepSeekClient, private val upstream: DeepSeekClient,
) : GatewayProvider { ) : GatewayProvider {
@@ -85,7 +102,9 @@ class DeepSeekProvider(
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
}, },
) : this(KtorDeepSeekClient(client, config, json)) credentialResolver: ProviderApiKeyResolver =
StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey),
) : this(createDeepSeekClient(client, config, json, credentialResolver))
override val descriptor = ProviderDescriptor( override val descriptor = ProviderDescriptor(
id = "deepseek", id = "deepseek",
@@ -145,8 +164,14 @@ class DeepSeekProvider(
require(request.maxOutputTokens == request.executionPolicy.maxOutputTokens) { require(request.maxOutputTokens == request.executionPolicy.maxOutputTokens) {
"maxOutputTokens must match the server execution policy" "maxOutputTokens must match the server execution policy"
} }
require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) { require(
"DeepSeek web search is not configured" 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) { require(request.executionPolicy.tools == GatewayToolsMode.DISABLED) {
"DeepSeek tools are not configured" "DeepSeek tools are not configured"
@@ -176,11 +201,18 @@ class KtorDeepSeekClient(
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
}, },
private val credentialResolver: ProviderApiKeyResolver =
StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey),
) : DeepSeekClient { ) : DeepSeekClient {
override suspend fun complete( override suspend fun complete(
request: TextProviderRequest, request: TextProviderRequest,
output: ProviderOutput, output: ProviderOutput,
): ProviderUsage { ): 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( val payload = DeepSeekChatRequest(
model = config.modelFor(request.executionPolicy.modelProfile), model = config.modelFor(request.executionPolicy.modelProfile),
messages = controlledMessages(request), messages = controlledMessages(request),
@@ -203,7 +235,7 @@ class KtorDeepSeekClient(
) )
return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") { return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") {
bearerAuth(config.apiKey) bearerAuth(apiKey)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json) header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json)
header("X-Request-ID", request.requestId) header("X-Request-ID", request.requestId)
@@ -491,34 +523,11 @@ class KtorDeepSeekClient(
return bytes return bytes
} }
private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> { private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> =
val system = when (request.capability) { listOf(
GatewayCapability.POLISH -> ChatMessage("system", deepSeekSystemInstruction(request)),
"Polish the user's text while preserving meaning. Return only the polished text." ChatMessage("user", deepSeekUserText(request)),
)
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 GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort = private fun GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort =
when (this) { 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 @Serializable
private data class DeepSeekChatRequest( private data class DeepSeekChatRequest(
val model: String, val model: String,
@@ -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<ByteArray>()
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<ByteReadChannel>().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<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException(
"DeepSeek Responses returned an unexpected content type",
)
}
val result = parseResponse(response.body<ByteReadChannel>().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<DeepSeekResponsesMessage>,
val tools: List<DeepSeekResponsesTool>,
@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,
)
@@ -1,5 +1,8 @@
package com.osglab.account.features.gateway.providers.volcengine 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.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability import com.osglab.account.features.gateway.models.GatewayCapability
@@ -103,6 +106,8 @@ class KtorVolcengineAsrTransport(
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
}, },
private val credentialResolver: ProviderApiKeyResolver =
StaticProviderApiKeyResolver(volcengineApiKey = config.apiKey),
) : VolcengineAsrTransport, VolcengineStreamingClient { ) : VolcengineAsrTransport, VolcengineStreamingClient {
override suspend fun transcribe( override suspend fun transcribe(
request: AsrProviderRequest, request: AsrProviderRequest,
@@ -131,6 +136,9 @@ class KtorVolcengineAsrTransport(
var outputBytes = 0L var outputBytes = 0L
var frameCount = 0 var frameCount = 0
val sequenceValidator = SaucSequenceValidator() 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) { withTimeout(config.responseTimeoutMillis) {
client.webSocket( client.webSocket(
@@ -140,7 +148,6 @@ class KtorVolcengineAsrTransport(
headers.append("X-Api-Request-Id", providerRequestId) headers.append("X-Api-Request-Id", providerRequestId)
headers.append("X-Api-Connect-Id", providerRequestId) headers.append("X-Api-Connect-Id", providerRequestId)
headers.append("X-Api-Sequence", "-1") headers.append("X-Api-Sequence", "-1")
val apiKey = config.apiKey?.takeIf(String::isNotBlank)
if (apiKey != null) { if (apiKey != null) {
headers.append("X-Api-Key", apiKey) headers.append("X-Api-Key", apiKey)
} else { } else {
@@ -458,7 +458,7 @@ private suspend fun ApplicationCall.respondGatewayFailure(
is OobeFeatureAlreadyUsedException -> respondGatewayError( is OobeFeatureAlreadyUsedException -> respondGatewayError(
HttpStatusCode.Conflict, HttpStatusCode.Conflict,
"oobe_feature_already_used", "oobe_feature_already_used",
"This OOBE feature has already been used successfully", "This OOBE feature has already been used successfully in this session",
requestId, requestId,
) )
@@ -61,12 +61,21 @@ class GatewayTaskPolicyResolver(
GatewayTaskKind.AI_QUESTION -> reasoningPolicy( GatewayTaskKind.AI_QUESTION -> reasoningPolicy(
taskKind = taskKind, taskKind = taskKind,
effort = config.aiReasoningEffort, 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), maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
) )
GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy( GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy(
taskKind = taskKind, taskKind = taskKind,
effort = config.agentReasoningEffort, effort = config.agentReasoningEffort,
webSearch = GatewayWebSearchMode.DISABLED,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens), maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
) )
} }
@@ -89,13 +98,14 @@ class GatewayTaskPolicyResolver(
private fun reasoningPolicy( private fun reasoningPolicy(
taskKind: GatewayTaskKind, taskKind: GatewayTaskKind,
effort: GatewayReasoningEffort, effort: GatewayReasoningEffort,
webSearch: GatewayWebSearchMode,
maxOutputTokens: Int, maxOutputTokens: Int,
) = GatewayTaskExecutionPolicy( ) = GatewayTaskExecutionPolicy(
taskKind = taskKind, taskKind = taskKind,
modelProfile = GatewayModelProfile.REASONING, modelProfile = GatewayModelProfile.REASONING,
thinking = GatewayThinkingMode.ENABLED, thinking = GatewayThinkingMode.ENABLED,
reasoningEffort = effort, reasoningEffort = effort,
webSearch = GatewayWebSearchMode.DISABLED, webSearch = webSearch,
tools = GatewayToolsMode.DISABLED, tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = true, allowEmptyContentRetry = true,
maxOutputTokens = maxOutputTokens, maxOutputTokens = maxOutputTokens,
@@ -125,6 +135,7 @@ class GatewayTaskPolicyResolver(
) )
val AI_TASKS = setOf( val AI_TASKS = setOf(
GatewayTaskKind.AI_QUESTION, GatewayTaskKind.AI_QUESTION,
GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
GatewayTaskKind.CLIPBOARD_TRANSFORM, GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayTaskKind.CUSTOM_SKILL, GatewayTaskKind.CUSTOM_SKILL,
) )
@@ -1,6 +1,7 @@
package com.osglab.account.features.gateway.services package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest 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.ProviderRequest
import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter import com.osglab.account.features.gateway.models.UsageMeter
@@ -29,10 +30,18 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
// covers server-controlled system messages and chat framing. // covers server-controlled system messages and chat framing.
val inputBytes = request.input.encodeToByteArray().size.toLong() val inputBytes = request.input.encodeToByteArray().size.toLong()
val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L
val input = Math.addExact( val requestInput = Math.addExact(
Math.addExact(inputBytes, contextBytes), Math.addExact(inputBytes, contextBytes),
LLM_PROMPT_OVERHEAD_TOKENS, 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() val output = request.maxOutputTokens.toLong()
return ProviderUsageEstimate( return ProviderUsageEstimate(
meter = UsageMeter.LLM_TOKEN, meter = UsageMeter.LLM_TOKEN,
@@ -43,4 +52,5 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
} }
private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L
private const val WEB_SEARCH_INPUT_TOKEN_ALLOWANCE = 32_000L
} }
@@ -52,6 +52,7 @@ private object OobeRefreshTokensTable : Table("oobe_gateway_refresh_tokens") {
} }
private object OobeClaimsTable : Table("oobe_gateway_claims") { private object OobeClaimsTable : Table("oobe_gateway_claims") {
val grantId = varchar("grant_id", 36)
val subjectId = varchar("subject_id", 36) val subjectId = varchar("subject_id", 36)
val feature = varchar("feature", 32) val feature = varchar("feature", 32)
val requestId = varchar("request_id", 64) val requestId = varchar("request_id", 64)
@@ -59,7 +60,7 @@ private object OobeClaimsTable : Table("oobe_gateway_claims") {
val expiresAt = timestamp("expires_at") val expiresAt = timestamp("expires_at")
val createdAt = timestamp("created_at") val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_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") { private object OobeProviderRequestsTable : Table("oobe_provider_requests") {
@@ -236,7 +237,7 @@ class ExposedOobeRepository(
expiresAt: Instant, expiresAt: Instant,
now: Instant, now: Instant,
): OobeRequestClaim? = databaseFactory.query { ): OobeRequestClaim? = databaseFactory.query {
val key = claimKey(request.subjectId, request.feature.name) val key = claimKey(request.grantId, request.feature.name)
val reclaimed = OobeClaimsTable.update({ val reclaimed = OobeClaimsTable.update({
key and key and
(OobeClaimsTable.status eq CLAIMED) and (OobeClaimsTable.status eq CLAIMED) and
@@ -247,6 +248,7 @@ class ExposedOobeRepository(
it[updatedAt] = now it[updatedAt] = now
} == 1 } == 1
val inserted = !reclaimed && OobeClaimsTable.insertIgnore { val inserted = !reclaimed && OobeClaimsTable.insertIgnore {
it[grantId] = request.grantId
it[subjectId] = request.subjectId it[subjectId] = request.subjectId
it[feature] = request.feature.name it[feature] = request.feature.name
it[requestId] = request.requestId it[requestId] = request.requestId
@@ -269,7 +271,7 @@ class ExposedOobeRepository(
it[createdAt] = now it[createdAt] = now
}.insertedCount == 1 }.insertedCount == 1
if (!auditInserted) throw OobeRequestAlreadyClaimedException() 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) { override suspend fun markStarted(claim: OobeRequestClaim) {
@@ -280,7 +282,7 @@ class ExposedOobeRepository(
databaseFactory.query { databaseFactory.query {
val now = clock.instant() val now = clock.instant()
val claimChanged = OobeClaimsTable.update({ 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.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED) (OobeClaimsTable.status eq CLAIMED)
}) { }) {
@@ -308,7 +310,7 @@ class ExposedOobeRepository(
override suspend fun release(claim: OobeRequestClaim, errorCode: String) { override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
databaseFactory.query { databaseFactory.query {
OobeClaimsTable.deleteWhere { OobeClaimsTable.deleteWhere {
claimKey(claim.subjectId, claim.feature.name) and claimKey(claim.grantId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and (OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED) (OobeClaimsTable.status eq CLAIMED)
} }
@@ -332,7 +334,7 @@ class ExposedOobeRepository(
// Fail closed: an uncertain provider outcome must never become // Fail closed: an uncertain provider outcome must never become
// reclaimable after the temporary claim TTL. // reclaimable after the temporary claim TTL.
OobeClaimsTable.update({ OobeClaimsTable.update({
claimKey(claim.subjectId, claim.feature.name) and claimKey(claim.grantId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and (OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED) (OobeClaimsTable.status eq CLAIMED)
}) { }) {
@@ -377,8 +379,8 @@ private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeG
expiresAt = this[OobeRefreshTokensTable.expiresAt], expiresAt = this[OobeRefreshTokensTable.expiresAt],
) )
private fun claimKey(subjectId: String, feature: String) = private fun claimKey(grantId: String, feature: String) =
(OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature) (OobeClaimsTable.grantId eq grantId) and (OobeClaimsTable.feature eq feature)
private fun requestKey(claim: OobeRequestClaim) = private fun requestKey(claim: OobeRequestClaim) =
(OobeProviderRequestsTable.subjectId eq claim.subjectId) and (OobeProviderRequestsTable.subjectId eq claim.subjectId) and
@@ -103,6 +103,7 @@ sealed interface OobeRefreshRotationResult {
data class OobeRequestClaim( data class OobeRequestClaim(
val subjectId: String, val subjectId: String,
val grantId: String,
val feature: OobeFeature, val feature: OobeFeature,
val requestId: String, val requestId: String,
) )
@@ -39,4 +39,4 @@ class OobeRequestAlreadyClaimedException :
RuntimeException("The OOBE provider request ID has already been used") RuntimeException("The OOBE provider request ID has already been used")
class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) : 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")
@@ -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;
@@ -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;
@@ -238,6 +238,20 @@ class DeploymentConsistencyTest : FunSpec({
keyboardSchema shouldNotContain "hostApplication" 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") { test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml") val compose = root.read("compose.yaml")
@@ -404,6 +418,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/auth/session", "/v1/admin/auth/session",
"/v1/admin/auth/login", "/v1/admin/auth/login",
"/v1/admin/auth/logout", "/v1/admin/auth/logout",
"/v1/admin/providers",
"/v1/admin/providers/{providerId}/api-key",
"/v1/admin/overview", "/v1/admin/overview",
"/v1/admin/referrals", "/v1/admin/referrals",
"/v1/admin/analytics", "/v1/admin/analytics",
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://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 "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 "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract" runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor" runner shouldContain "first ledger page omitted nextCursor"
@@ -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.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType 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.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
@@ -482,6 +488,74 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR"""" response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
} }
@Test
fun `provider status is super admin only and never exposes API keys`() = testApplication {
val credentialService = mockk<GatewayCredentialService>()
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<GatewayCredentialService>(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<GatewayCredentialService>()
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( 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), operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true), auditService: AdminAuditService = mockk(relaxed = true),
usersService: AdminUsersService = mockk(relaxed = true), usersService: AdminUsersService = mockk(relaxed = true),
credentialService: GatewayCredentialService? = null,
mtlsRequired: Boolean = true, mtlsRequired: Boolean = true,
) { ) {
install(ContentNegotiation) { install(ContentNegotiation) {
@@ -513,6 +588,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
grantService = grantService, grantService = grantService,
operatorService = operatorService, operatorService = operatorService,
auditService = auditService, 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"}""", """{"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"}""")
}
@@ -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<CredentialMySqlContainer>(image)
@@ -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<InvalidProviderApiKeyException> {
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
}
}
@@ -63,6 +63,7 @@ class TextRequestPolicyTest : StringSpec({
"translation" to GatewayTaskKind.TRANSLATION, "translation" to GatewayTaskKind.TRANSLATION,
"edit_last_input" to GatewayTaskKind.EDIT_LAST_INPUT, "edit_last_input" to GatewayTaskKind.EDIT_LAST_INPUT,
"ai_question" to GatewayTaskKind.AI_QUESTION, "ai_question" to GatewayTaskKind.AI_QUESTION,
"current_information_question" to GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
"clipboard_transform" to GatewayTaskKind.CLIPBOARD_TRANSFORM, "clipboard_transform" to GatewayTaskKind.CLIPBOARD_TRANSFORM,
"custom_skill" to GatewayTaskKind.CUSTOM_SKILL, "custom_skill" to GatewayTaskKind.CUSTOM_SKILL,
"agent_planning" to GatewayTaskKind.AGENT_PLANNING, "agent_planning" to GatewayTaskKind.AGENT_PLANNING,
@@ -1,12 +1,16 @@
package com.osglab.account.features.gateway.providers.deepseek package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.models.GatewayCapability 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.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockEngine
@@ -19,6 +23,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive 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<ByteArray>()
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<ByteArray>()
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<String>()
val requestBodies = mutableListOf<String>()
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" { "retries one buffered empty result and returns the successful retry" {
var attempts = 0 var attempts = 0
val provider = DeepSeekProvider( val provider = DeepSeekProvider(
@@ -300,14 +458,41 @@ class DeepSeekClientTest : StringSpec({
client.close() client.close()
} }
} }
"resolves the bearer token separately for each new upstream request" {
val authorizationHeaders = mutableListOf<String?>()
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( private fun client(
responseBody: String, responseBody: String,
contentType: ContentType = ContentType.Application.Json, contentType: ContentType = ContentType.Application.Json,
onRequest: suspend (String) -> Unit = {}, onRequest: suspend (String) -> Unit = {},
onPath: suspend (String) -> Unit = {},
onAuthorization: suspend (String?) -> Unit = {},
) = HttpClient( ) = HttpClient(
MockEngine { request -> MockEngine { request ->
onPath(request.url.encodedPath)
onAuthorization(request.headers[HttpHeaders.Authorization])
onRequest(request.body.toByteArray().decodeToString()) onRequest(request.body.toByteArray().decodeToString())
respond( respond(
content = responseBody, content = responseBody,
@@ -324,8 +509,11 @@ private fun client(
private fun request( private fun request(
capability: GatewayCapability = GatewayCapability.AI, capability: GatewayCapability = GatewayCapability.AI,
taskKind: GatewayTaskKind? = null, taskKind: GatewayTaskKind? = null,
webSearch: GatewayWebSearchMode = GatewayWebSearchMode.DISABLED,
): TextProviderRequest { ): TextProviderRequest {
val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32) val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32).copy(
webSearch = webSearch,
)
return TextProviderRequest( return TextProviderRequest(
requestId = "deepseek-request", requestId = "deepseek-request",
capability = capability, capability = capability,
@@ -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.GatewayRequestSource
import com.osglab.account.features.gateway.models.GatewayTaskKind import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayThinkingMode 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.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest import com.osglab.account.features.gateway.models.ProviderRequest
@@ -135,6 +136,26 @@ class GatewayRequestIdTest : StringSpec({
GatewayThinkingMode.DISABLED 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) { private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) {
@@ -68,7 +68,7 @@ class GatewayServiceBillingTest : StringSpec({
credits.settled.shouldContainExactly(RESERVATION_ID to 21L) credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
credits.released shouldBe emptyList() credits.released shouldBe emptyList()
credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN
credits.lastEstimate?.inputUnits shouldBe 261L credits.lastEstimate?.inputUnits shouldBe 261L + 32_000L
credits.lastEstimate?.outputUnits shouldBe 32L credits.lastEstimate?.outputUnits shouldBe 32L
} }
@@ -44,12 +44,42 @@ class GatewayTaskPolicyResolverTest : StringSpec({
} }
} }
"enables explicit high-effort reasoning only for question and agent tasks" { "allows model-selected search for ordinary AI questions" {
listOf( val policy = resolver.resolve(
GatewayCapability.AI to GatewayTaskKind.AI_QUESTION, GatewayCapability.AI,
GatewayCapability.AGENT to GatewayTaskKind.AGENT_PLANNING, GatewayTaskKind.AI_QUESTION,
).forEach { (capability, taskKind) -> 512,
val policy = resolver.resolve(capability, taskKind, 512) )
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.modelProfile shouldBe GatewayModelProfile.REASONING
policy.thinking shouldBe GatewayThinkingMode.ENABLED policy.thinking shouldBe GatewayThinkingMode.ENABLED
@@ -58,7 +88,6 @@ class GatewayTaskPolicyResolverTest : StringSpec({
policy.tools shouldBe GatewayToolsMode.DISABLED policy.tools shouldBe GatewayToolsMode.DISABLED
policy.allowEmptyContentRetry shouldBe true policy.allowEmptyContentRetry shouldBe true
} }
}
"rejects capability and task mismatches without inspecting content" { "rejects capability and task mismatches without inspecting content" {
shouldThrow<IllegalArgumentException> { shouldThrow<IllegalArgumentException> {
@@ -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,
)
}
@@ -61,6 +61,23 @@ class OobeGatewayServiceTest : StringSpec({
credits.calls shouldBe 0 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" { "releases the feature claim when the provider fails" {
val credits = CountingCredits() val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository() val oobe = FakeOobeExecutionRepository()
@@ -154,7 +171,7 @@ private class CountingCredits : CreditReservationPort {
} }
private class FakeOobeExecutionRepository : OobeRepository { private class FakeOobeExecutionRepository : OobeRepository {
private val claimedFeatures = mutableSetOf<OobeFeature>() private val claimedFeatures = mutableSetOf<Pair<String, OobeFeature>>()
val consumed = mutableListOf<OobeRequestClaim>() val consumed = mutableListOf<OobeRequestClaim>()
val released = mutableListOf<OobeRequestClaim>() val released = mutableListOf<OobeRequestClaim>()
@@ -163,8 +180,9 @@ private class FakeOobeExecutionRepository : OobeRepository {
expiresAt: Instant, expiresAt: Instant,
now: Instant, now: Instant,
): OobeRequestClaim? { ): OobeRequestClaim? {
if (!claimedFeatures.add(request.feature)) return null val claimKey = request.grantId to request.feature
return OobeRequestClaim(request.subjectId, request.feature, request.requestId) if (!claimedFeatures.add(claimKey)) return null
return OobeRequestClaim(request.subjectId, request.grantId, request.feature, request.requestId)
} }
override suspend fun markStarted(claim: OobeRequestClaim) = Unit override suspend fun markStarted(claim: OobeRequestClaim) = Unit
@@ -174,7 +192,7 @@ private class FakeOobeExecutionRepository : OobeRepository {
} }
override suspend fun release(claim: OobeRequestClaim, errorCode: String) { override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
claimedFeatures -= claim.feature claimedFeatures -= (claim.grantId to claim.feature)
released += claim released += claim
} }
@@ -27,7 +27,7 @@ import java.time.Instant
import java.util.UUID import java.util.UUID
class OobeRepositoryIntegrationTest : FunSpec({ 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 -> withOobeDatabase { config, databaseFactory ->
val repository = ExposedOobeRepository(databaseFactory) val repository = ExposedOobeRepository(databaseFactory)
val now = Instant.parse("2026-08-21T01:00:00Z") val now = Instant.parse("2026-08-21T01:00:00Z")
@@ -72,6 +72,27 @@ class OobeRepositoryIntegrationTest : FunSpec({
now, now,
) shouldBe null ) 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, "accounts") shouldBe 0
databaseCount(config, "credit_ledger") shouldBe 0 databaseCount(config, "credit_ledger") shouldBe 0
databaseCount(config, "devicecheck_trial_claims") shouldBe 0 databaseCount(config, "devicecheck_trial_claims") shouldBe 0