Add runtime provider controls and searchable AI routing
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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))}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 必须为 1–512 个字符,且不能包含换行");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确定替换 ${name} API Key?保存后,新请求将立即使用该 Key。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateProviderApiKey(providerId, {
|
||||||
|
apiKey: normalized,
|
||||||
|
});
|
||||||
|
setApiKey("");
|
||||||
|
onUpdated(updated);
|
||||||
|
toast.success(`${name} API Key 已替换`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : `${name} API Key 替换失败`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'@'%';
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+44
@@ -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)"
|
||||||
|
}
|
||||||
+90
@@ -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]),
|
||||||
|
)
|
||||||
+60
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+85
-32
@@ -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,
|
||||||
|
|||||||
+356
@@ -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,
|
||||||
|
)
|
||||||
+8
-1
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-1
@@ -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"}""")
|
||||||
|
}
|
||||||
|
|||||||
+121
@@ -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)
|
||||||
+136
@@ -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,
|
||||||
|
|||||||
+189
-1
@@ -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) {
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -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> {
|
||||||
|
|||||||
+40
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user